add filter source_type

This commit is contained in:
vergiLgood1 2025-05-12 12:24:50 +07:00
parent 77c865958a
commit 2a8f249d0c
21 changed files with 2332 additions and 1050 deletions

View File

@ -4,6 +4,8 @@ import {
getCrimeByYearAndMonth,
getCrimeCategories,
getCrimes,
getCrimesTypes,
getRecentIncidents,
} from '../action';
export const useGetAvailableYears = () => {
@ -36,3 +38,17 @@ export const useGetCrimeCategories = () => {
queryFn: () => getCrimeCategories(),
});
};
export const useGetCrimeTypes = () => {
return useQuery({
queryKey: ['crime-types'],
queryFn: () => getCrimesTypes(),
});
}
export const useGetRecentIncidents = () => {
return useQuery({
queryKey: ['recent-incidents'],
queryFn: () => getRecentIncidents(),
});
}

View File

@ -1,24 +1,26 @@
'use server';
"use server";
import { createClient } from '@/app/_utils/supabase/client';
import { getSeverity } from "@/app/_utils/crime";
import { createClient } from "@/app/_utils/supabase/client";
import {
ICrimes,
ICrimesByYearAndMonth,
IDistanceResult,
} from '@/app/_utils/types/crimes';
import { getInjection } from '@/di/container';
import db from '@/prisma/db';
IIncidentLogs,
} from "@/app/_utils/types/crimes";
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';
} from "@/src/entities/errors/auth";
import { InputParseError } from "@/src/entities/errors/common";
export async function getAvailableYears() {
const instrumentationService = getInjection('IInstrumentationService');
const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction(
'Available Years',
"Available Years",
{ recordResponse: true },
async () => {
try {
@ -26,50 +28,38 @@ export async function getAvailableYears() {
select: {
year: true,
},
distinct: ['year'],
distinct: ["year"],
orderBy: {
year: 'asc',
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.'
"There was an error with the credentials. Please try again or contact support.",
);
}
const crashReporterService = getInjection('ICrashReporterService');
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.'
"An error happened. The developers have been notified. Please try again later.",
);
}
}
},
);
}
export async function getCrimeCategories() {
const instrumentationService = getInjection('IInstrumentationService');
const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction(
'Crime Categories',
"Crime Categories",
{ recordResponse: true },
async () => {
try {
@ -84,41 +74,29 @@ export async function getCrimeCategories() {
return categories;
} 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.'
"There was an error with the credentials. Please try again or contact support.",
);
}
const crashReporterService = getInjection('ICrashReporterService');
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.'
"An error happened. The developers have been notified. Please try again later.",
);
}
}
},
);
}
export async function getCrimes(): Promise<ICrimes[]> {
const instrumentationService = getInjection('IInstrumentationService');
const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction(
'District Crime Data',
"District Crime Data",
{ recordResponse: true },
async () => {
try {
@ -173,54 +151,121 @@ export async function getCrimes(): Promise<ICrimes[]> {
return crimes;
} 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.'
"There was an error with the credentials. Please try again or contact support.",
);
}
const crashReporterService = getInjection('ICrashReporterService');
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.'
"An error happened. The developers have been notified. Please try again later.",
);
}
}
},
);
}
export async function getRecentIncidents(): Promise<IIncidentLogs[]> {
const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction(
"Recent Incidents",
{ recordResponse: true },
async () => {
try {
const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const incidents = await db.incident_logs.findMany({
where: {
time: {
gte: yesterday,
lte: now,
},
},
orderBy: {
time: "desc",
},
include: {
crime_categories: {
select: {
name: true,
type: true,
},
},
locations: {
select: {
districts: {
select: {
name:true
}
},
address: true,
latitude: true,
longitude: true,
},
},
},
});
// Map DB result to IIncidentLogs interface
return incidents.map((incident) => ({
id: incident.id,
user_id: incident.user_id,
latitude: incident.locations?.latitude ?? null,
longitude: incident.locations?.longitude ?? null,
district: incident.locations.districts.name ?? "",
address: incident.locations?.address ?? "",
category: incident.crime_categories?.name ?? "",
source: incident.source ?? "",
description: incident.description ?? "",
verified: incident.verified ?? false,
severity: getSeverity(incident.crime_categories.name),
timestamp: incident.time,
created_at: incident.created_at ?? incident.time ?? new Date(),
updated_at: incident.updated_at ?? incident.time ?? new Date(),
}));
} catch (err) {
if (err instanceof InputParseError) {
throw new InputParseError(err.message);
}
if (err instanceof AuthenticationError) {
throw new AuthenticationError(
"There was an error with the credentials. Please try again or contact support.",
);
}
const crashReporterService = getInjection("ICrashReporterService");
crashReporterService.report(err);
throw new Error(
"An error happened. The developers have been notified. Please try again later.",
);
}
},
);
}
export async function getCrimeByYearAndMonth(
year: number,
month: number | 'all'
month: number | "all",
): Promise<ICrimesByYearAndMonth[]> {
const instrumentationService = getInjection('IInstrumentationService');
const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction(
'District Crime Data',
"District Crime Data",
{ recordResponse: true },
async () => {
try {
// Build where clause conditionally based on provided parameters
const whereClause: any = {
year: year, // Always filter by year now since "all" is removed
year: year,
};
// Only add month to filter if it's not "all"
if (month !== 'all') {
if (month !== "all") {
whereClause.month = month;
}
@ -231,7 +276,7 @@ export async function getCrimeByYearAndMonth(
select: {
name: true,
geographics: {
where: { year }, // Match geographics to selected year
where: { year },
select: {
address: true,
land_area: true,
@ -241,7 +286,7 @@ export async function getCrimeByYearAndMonth(
},
},
demographics: {
where: { year }, // Match demographics to selected year
where: { year },
select: {
number_of_unemployed: true,
population: true,
@ -275,15 +320,12 @@ export async function getCrimeByYearAndMonth(
},
});
// Process the data to transform geographics and demographics from array to single object
const processedCrimes = crimes.map((crime) => {
return {
...crime,
districts: {
...crime.districts,
// Convert geographics array to single object matching the year
geographics: crime.districts.geographics[0] || null,
// Convert demographics array to single object matching the year
demographics: crime.districts.demographics[0] || null,
},
};
@ -297,17 +339,17 @@ export async function getCrimeByYearAndMonth(
if (err instanceof AuthenticationError) {
throw new AuthenticationError(
'There was an error with the credentials. Please try again or contact support.'
"There was an error with the credentials. Please try again or contact support.",
);
}
const crashReporterService = getInjection('ICrashReporterService');
const crashReporterService = getInjection("ICrashReporterService");
crashReporterService.report(err);
throw new Error(
'An error happened. The developers have been notified. Please try again later.'
"An error happened. The developers have been notified. Please try again later.",
);
}
}
},
);
}
@ -319,37 +361,77 @@ export async function getCrimeByYearAndMonth(
*/
export async function calculateDistances(
p_unit_id?: string,
p_district_id?: string
p_district_id?: string,
): Promise<IDistanceResult[]> {
const instrumentationService = getInjection('IInstrumentationService');
const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction(
'Calculate Distances',
"Calculate Distances",
{ recordResponse: true },
async () => {
const supabase = createClient();
try {
const { data, error } = await supabase.rpc(
'calculate_unit_incident_distances',
"calculate_unit_incident_distances",
{
p_unit_id: p_unit_id || null,
p_district_id: p_district_id || null,
}
},
);
if (error) {
console.error('Error calculating distances:', error);
console.error("Error calculating distances:", error);
return [];
}
return data as IDistanceResult[] || [];
} catch (error) {
const crashReporterService = getInjection('ICrashReporterService');
const crashReporterService = getInjection("ICrashReporterService");
crashReporterService.report(error);
console.error('Failed to calculate distances:', error);
console.error("Failed to calculate distances:", error);
return [];
}
}
},
);
}
export async function getCrimesTypes(): Promise<string[]> {
const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction(
"Crime Types",
{ recordResponse: true },
async () => {
try {
const types = await db.crimes.findMany({
distinct: ["source_type"],
select: {
source_type: true,
},
});
// Return a clean array of strings with no nulls
return types
.map((t) => t.source_type)
.filter((t): t is string => t !== null && t !== undefined);
} catch (err) {
if (err instanceof InputParseError) {
throw new InputParseError(err.message);
}
if (err instanceof AuthenticationError) {
throw new AuthenticationError(
"There was an error with the credentials. Please try again or contact support.",
);
}
const crashReporterService = getInjection("ICrashReporterService");
crashReporterService.report(err);
throw new Error(
"An error happened. The developers have been notified. Please try again later.",
);
}
},
);
}

View File

@ -27,6 +27,7 @@ interface CrimeSidebarProps {
selectedMonth?: number | "all"
crimes: ICrimes[]
isLoading?: boolean
sourceType?: string
}
export default function CrimeSidebar({
@ -37,6 +38,7 @@ export default function CrimeSidebar({
selectedMonth,
crimes = [],
isLoading = false,
sourceType = "cbt",
}: CrimeSidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
const [activeTab, setActiveTab] = useState("incidents")
@ -60,13 +62,19 @@ export default function CrimeSidebar({
return () => clearInterval(timer)
}, [])
// Set default tab based on source type
useEffect(() => {
if (sourceType === "cbu") {
setActiveTab("incidents")
}
}, [sourceType])
// Format date with selected year and month if provided
const getDisplayDate = () => {
// If we have a specific month selected, use that for display
if (selectedMonth && selectedMonth !== 'all') {
const date = new Date()
date.setFullYear(selectedYear)
date.setMonth(Number(selectedMonth) - 1) // Month is 0-indexed in JS Date
date.setMonth(Number(selectedMonth) - 1)
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
@ -74,7 +82,6 @@ export default function CrimeSidebar({
}).format(date)
}
// Otherwise show today's date
return new Intl.DateTimeFormat('en-US', {
weekday: 'long',
year: 'numeric',
@ -91,7 +98,6 @@ export default function CrimeSidebar({
hour12: true
}).format(currentTime)
// Generate a time period display for the current view
const getTimePeriodDisplay = () => {
if (selectedMonth && selectedMonth !== 'all') {
return `${getMonthName(Number(selectedMonth))} ${selectedYear}`
@ -99,31 +105,8 @@ export default function CrimeSidebar({
return `${selectedYear} - All months`
}
// Function to fly to incident location when clicked
const handleIncidentClick = (incident: any) => {
if (!map || !incident.longitude || !incident.latitude) return
// Fly to the incident location
// map.flyTo({
// center: [incident.longitude, incident.latitude],
// zoom: 15,
// pitch: 0,
// bearing: 0,
// duration: 1500,
// easing: (t) => t * (2 - t), // easeOutQuad
// })
// // Create and dispatch a custom event for the incident click
// const customEvent = new CustomEvent("incident_click", {
// detail: incident,
// bubbles: true
// })
// if (map.getMap().getCanvas()) {
// map.getMap().getCanvas().dispatchEvent(customEvent)
// } else {
// document.dispatchEvent(customEvent)
// }
}
return (
@ -135,7 +118,6 @@ export default function CrimeSidebar({
<div className="relative h-full flex items-stretch">
<div className="bg-background backdrop-blur-sm border-r border-sidebar-border h-full w-[420px]">
<div className="p-4 text-sidebar-foreground h-full flex flex-col max-h-full overflow-hidden">
{/* Header with improved styling */}
<CardHeader className="p-0 pb-4 shrink-0 relative">
<div className="absolute top-0 right-0">
<Button
@ -155,6 +137,11 @@ export default function CrimeSidebar({
<div>
<CardTitle className="text-xl font-semibold">
Crime Analysis
{sourceType && (
<span className="ml-2 text-xs font-normal px-2 py-1 bg-sidebar-accent rounded-full">
{sourceType.toUpperCase()}
</span>
)}
</CardTitle>
{!isLoading && (
<CardDescription className="text-sm text-sidebar-foreground/70">
@ -165,7 +152,6 @@ export default function CrimeSidebar({
</div>
</CardHeader>
{/* Improved tabs with pill style */}
<Tabs
defaultValue="incidents"
className="w-full flex-1 flex flex-col overflow-hidden"
@ -173,12 +159,14 @@ export default function CrimeSidebar({
onValueChange={setActiveTab}
>
<TabsList className="w-full mb-4 bg-sidebar-accent p-1 rounded-full">
<TabsTrigger
value="incidents"
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
>
Dashboard
</TabsTrigger>
<TabsTrigger
value="statistics"
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
@ -211,6 +199,7 @@ export default function CrimeSidebar({
</div>
) : (
<>
<TabsContent value="incidents" className="m-0 p-0 space-y-4">
<SidebarIncidentsTab
crimeStats={crimeStats}
@ -226,6 +215,8 @@ export default function CrimeSidebar({
handleIncidentClick={handleIncidentClick}
activeIncidentTab={activeIncidentTab}
setActiveIncidentTab={setActiveIncidentTab}
sourceType={sourceType}
// setActiveTab={setActiveTab} // Pass setActiveTab function
/>
</TabsContent>
@ -234,11 +225,13 @@ export default function CrimeSidebar({
crimeStats={crimeStats}
selectedMonth={selectedMonth}
selectedYear={selectedYear}
sourceType={sourceType}
crimes={crimes}
/>
</TabsContent>
<TabsContent value="info" className="m-0 p-0 space-y-4">
<SidebarInfoTab />
<SidebarInfoTab sourceType={sourceType} />
</TabsContent>
</>
)}

View File

@ -1,5 +1,5 @@
import React from 'react'
import { AlertTriangle, AlertCircle, Clock, Shield, MapPin, ChevronLeft, ChevronRight, FileText, Calendar } from 'lucide-react'
import { AlertTriangle, AlertCircle, Clock, Shield, MapPin, ChevronLeft, ChevronRight, FileText, Calendar, ArrowRight, RefreshCw } from 'lucide-react'
import { Card, CardContent } from "@/app/_components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
import { Badge } from "@/app/_components/ui/badge"
@ -59,8 +59,9 @@ export function SidebarIncidentsTab({
handlePageChange,
handleIncidentClick,
activeIncidentTab,
setActiveIncidentTab
}: SidebarIncidentsTabProps) {
setActiveIncidentTab,
sourceType = "cbt"
}: SidebarIncidentsTabProps & { sourceType?: string }) {
const topCategories = crimeStats.categoryCounts ?
Object.entries(crimeStats.categoryCounts)
.sort((a, b) => b[1] - a[1])
@ -70,6 +71,58 @@ export function SidebarIncidentsTab({
return { type, count, percentage }
}) : []
// If source type is CBU, display warning instead of regular content
if (sourceType === "cbu") {
return (
<Card className="bg-gradient-to-r from-emerald-900/20 to-emerald-800/10 border border-emerald-500/30">
<CardContent className="p-6 flex flex-col items-center text-center">
<div className="bg-emerald-500/20 rounded-full p-3 mb-3">
<AlertTriangle className="h-8 w-8 text-emerald-400" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Limited Data View</h3>
<p className="text-white/80 mb-4">
The CBU data source only provides aggregated statistics without detailed incident information.
</p>
<div className="bg-black/20 rounded-lg p-3 w-full mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-white/60 text-sm">Current Data Source:</span>
<span className="font-medium text-emerald-400 text-sm">CBU</span>
</div>
<div className="flex items-center justify-between">
<span className="text-white/60 text-sm">Recommended:</span>
<span className="font-medium text-blue-400 text-sm">CBT</span>
</div>
</div>
<p className="text-white/70 text-sm mb-5">
To view detailed incident reports, individual crime records, and location-specific information, please switch to the CBT data source.
</p>
<div className="flex items-center gap-2 text-sm">
<Button
variant="outline"
size="sm"
className="border-emerald-500/50 hover:bg-emerald-500/20 text-emerald-300"
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Change Data Source
</Button>
</div>
<div className="w-full mt-6 pt-3 border-t border-emerald-500/20">
<p className="text-xs text-white/60">
The CBU (Crime By Unit) data provides insights at the district level, while CBT (Crime By Type) includes detailed incident-level information.
</p>
</div>
</CardContent>
</Card>
);
}
return (
<>
{/* Enhanced info card */}
@ -93,7 +146,7 @@ export function SidebarIncidentsTab({
<span className="text-sidebar-foreground/70">{location}</span>
</div>
<div className="flex items-center gap-2 bg-sidebar-accent/30 p-2 rounded-lg">
<AlertTriangle className="h-5 w-5 text-amber-400" />
<AlertTriangle className="h-5 w-5 text-emerald-400" />
<span>
<strong>{crimeStats.totalIncidents || 0}</strong> incidents reported
{selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`}
@ -116,8 +169,8 @@ export function SidebarIncidentsTab({
<SystemStatusCard
title="Recent Cases"
status={`${crimeStats?.recentIncidents?.length || 0}`}
statusIcon={<Clock className="h-4 w-4 text-amber-400" />}
statusColor="text-amber-400"
statusIcon={<Clock className="h-4 w-4 text-emerald-400" />}
statusColor="text-emerald-400"
updatedTime="Last 30 days"
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
borderColor="border-sidebar-border"
@ -243,7 +296,7 @@ export function SidebarIncidentsTab({
<div key={monthKey} className="mb-5">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5 text-amber-400" />
<Calendar className="h-3.5 w-3.5 text-emerald-400" />
<h4 className="font-medium text-xs">{formatMonthKey(monthKey)}</h4>
</div>
<Badge variant="secondary" className="h-5 text-[10px]">

View File

@ -5,7 +5,11 @@ import { Separator } from "@/app/_components/ui/separator"
import { CRIME_RATE_COLORS } from "@/app/_utils/const/map"
import { SidebarSection } from "../components/sidebar-section"
export function SidebarInfoTab() {
interface SidebarInfoTabProps {
sourceType?: string
}
export function SidebarInfoTab({ sourceType = "cbt" }: SidebarInfoTabProps) {
return (
<>
<SidebarSection title="Map Legend" icon={<Layers className="h-4 w-4 text-green-400" />}>
@ -29,123 +33,175 @@ export function SidebarInfoTab() {
<Separator className="bg-white/20 my-3" />
{/* Show different map markers based on source type */}
<div className="space-y-2">
<h4 className="font-medium mb-2 text-sm">Map Markers</h4>
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
<AlertCircle className="h-4 w-4 text-red-500" />
<span>Individual Incident</span>
</div>
{sourceType === "cbt" ? (
// Detailed incidents for CBT
<>
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
<AlertCircle className="h-4 w-4 text-red-500" />
<span>Individual Incident</span>
</div>
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
<div className="w-5 h-5 rounded-full bg-pink-400 flex items-center justify-center text-[10px] text-white">5</div>
<span className="font-bold">Incident Cluster</span>
</div>
</>
) : (
// Simplified view for CBU
<>
<div className="flex items-center gap-2 p-1.5 hover:bg-white/5 rounded-md transition-colors">
<div className="w-5 h-5 rounded-full bg-blue-400 flex items-center justify-center text-[10px] text-white">12</div>
<span className="font-bold">District Crime Count</span>
</div>
<div className="p-1.5 text-white/70">
Shows aggregated crime counts by district. Size indicates relative crime volume.
</div>
</>
)}
</div>
</CardContent>
</Card>
</SidebarSection>
{/* Show layers info based on source type */}
<SidebarSection title="Map Layers" icon={<Map className="h-4 w-4 text-blue-400" />}>
<Card className="bg-gradient-to-r from-zinc-800/80 to-zinc-900/80 border border-white/10">
<CardContent className="p-4 text-xs space-y-4">
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<AlertCircle className="h-4 w-4 text-cyan-400" />
<span>Incidents Layer</span>
</h4>
<p className="text-white/70 pl-6">
Shows individual crime incidents as map markers. Each marker represents a single crime report and is color-coded by category.
Click on any marker to see detailed information about the incident.
</p>
</div>
{sourceType === "cbt" ? (
// Show all layers for CBT
<>
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<AlertCircle className="h-4 w-4 text-cyan-400" />
<span>Incidents Layer</span>
</h4>
<p className="text-white/70 pl-6">
Shows individual crime incidents as map markers. Each marker represents a single crime report and is color-coded by category.
Click on any marker to see detailed information about the incident.
</p>
</div>
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Box className="h-4 w-4 text-pink-400" />
<span>Clusters Layer</span>
</h4>
<p className="text-white/70 pl-6">
Groups nearby incidents into clusters for better visibility at lower zoom levels. Numbers show incident count in each cluster.
Clusters are color-coded by size: blue (small), yellow (medium), pink (large).
Click on a cluster to zoom in and see individual incidents.
</p>
<div className="flex items-center gap-6 pl-6 pt-1">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-[#51bbd6]"></div>
<span>1-5</span>
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Box className="h-4 w-4 text-pink-400" />
<span>Clusters Layer</span>
</h4>
<p className="text-white/70 pl-6">
Groups nearby incidents into clusters for better visibility at lower zoom levels. Numbers show incident count in each cluster.
Clusters are color-coded by size: blue (small), yellow (medium), pink (large).
Click on a cluster to zoom in and see individual incidents.
</p>
<div className="flex items-center gap-6 pl-6 pt-1">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-[#51bbd6]"></div>
<span>1-5</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-[#f1f075]"></div>
<span>6-15</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-[#f28cb1]"></div>
<span>15+</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-[#f1f075]"></div>
<span>6-15</span>
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Thermometer className="h-4 w-4 text-orange-400" />
<span>Heatmap Layer</span>
</h4>
<p className="text-white/70 pl-6">
Shows crime density across regions, with warmer colors (red, orange) indicating higher crime concentration
and cooler colors (blue) showing lower concentration. Useful for identifying crime hotspots.
</p>
<div className="pl-6 pt-1">
<div className="h-2 w-full rounded-full bg-gradient-to-r from-blue-600 via-yellow-400 to-red-600"></div>
<div className="flex justify-between text-[10px] mt-1 text-white/70">
<span>Low</span>
<span>Density</span>
<span>High</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-[#f28cb1]"></div>
<span>15+</span>
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Users className="h-4 w-4 text-blue-400" />
<span>Units Layer</span>
</h4>
<p className="text-white/70 pl-6">
Displays police and security units as blue circles with connecting lines to nearby incidents.
Units are labeled and can be clicked to show more information and related incident details.
</p>
<div className="flex items-center gap-2 pl-6 pt-1">
<div className="w-4 h-4 rounded-full border-2 border-white bg-[#1e40af]"></div>
<span>Police/Security Unit</span>
</div>
</div>
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Clock className="h-4 w-4 text-yellow-400" />
<span>Timeline Layer</span>
</h4>
<p className="text-white/70 pl-6">
Shows time patterns of crime incidents with color-coded circles representing average times of day when
incidents occur in each district. Click for detailed time distribution analysis.
</p>
<div className="flex flex-wrap gap-x-5 gap-y-2 pl-6 pt-1">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#FFEB3B]"></div>
<span>Morning</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#FF9800]"></div>
<span>Afternoon</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#3F51B5]"></div>
<span>Evening</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#263238]"></div>
<span>Night</span>
</div>
</div>
</div>
</>
) : (
// Show limited layers info for CBU
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Box className="h-4 w-4 text-blue-400" />
<span>District Crime Data</span>
</h4>
<p className="text-white/70 pl-6">
Shows aggregated crime statistics by district. Each point represents the total crime count for a district.
Points are color-coded based on crime level: green (low), yellow (medium), red (high).
</p>
<p className="text-white/70 pl-6 mt-1">
The size of each point indicates the relative number of crimes reported in that district.
</p>
<div className="flex items-center gap-6 pl-6 pt-2">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: CRIME_RATE_COLORS.low }}></div>
<span>Low</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: CRIME_RATE_COLORS.medium }}></div>
<span>Medium</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: CRIME_RATE_COLORS.high }}></div>
<span>High</span>
</div>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Thermometer className="h-4 w-4 text-orange-400" />
<span>Heatmap Layer</span>
</h4>
<p className="text-white/70 pl-6">
Shows crime density across regions, with warmer colors (red, orange) indicating higher crime concentration
and cooler colors (blue) showing lower concentration. Useful for identifying crime hotspots.
</p>
<div className="pl-6 pt-1">
<div className="h-2 w-full rounded-full bg-gradient-to-r from-blue-600 via-yellow-400 to-red-600"></div>
<div className="flex justify-between text-[10px] mt-1 text-white/70">
<span>Low</span>
<span>Density</span>
<span>High</span>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Users className="h-4 w-4 text-blue-400" />
<span>Units Layer</span>
</h4>
<p className="text-white/70 pl-6">
Displays police and security units as blue circles with connecting lines to nearby incidents.
Units are labeled and can be clicked to show more information and related incident details.
</p>
<div className="flex items-center gap-2 pl-6 pt-1">
<div className="w-4 h-4 rounded-full border-2 border-white bg-[#1e40af]"></div>
<span>Police/Security Unit</span>
</div>
</div>
<div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm">
<Clock className="h-4 w-4 text-yellow-400" />
<span>Timeline Layer</span>
</h4>
<p className="text-white/70 pl-6">
Shows time patterns of crime incidents with color-coded circles representing average times of day when
incidents occur in each district. Click for detailed time distribution analysis.
</p>
<div className="flex flex-wrap gap-x-5 gap-y-2 pl-6 pt-1">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#FFEB3B]"></div>
<span>Morning</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#FF9800]"></div>
<span>Afternoon</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#3F51B5]"></div>
<span>Evening</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#263238]"></div>
<span>Night</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
</SidebarSection>
@ -170,6 +226,12 @@ export function SidebarInfoTab() {
<span>Last Updated</span>
<span className="font-medium">June 18, 2024</span>
</div>
{sourceType && (
<div className="flex justify-between mt-1">
<span>Data Source</span>
<span className="font-medium">{sourceType.toUpperCase()}</span>
</div>
)}
</div>
</CardContent>
</Card>
@ -203,17 +265,20 @@ export function SidebarInfoTab() {
</div>
</div>
<div className="flex gap-3 items-start">
<div className="bg-emerald-900/50 p-1.5 rounded-md">
<AlertTriangle className="h-3.5 w-3.5 text-emerald-400" />
{/* Show incident details help only for CBT */}
{sourceType === "cbt" && (
<div className="flex gap-3 items-start">
<div className="bg-emerald-900/50 p-1.5 rounded-md">
<AlertTriangle className="h-3.5 w-3.5 text-emerald-400" />
</div>
<div>
<span className="font-medium">Incidents</span>
<p className="text-white/70 mt-1">
Click on incident markers to view details about specific crime reports.
</p>
</div>
</div>
<div>
<span className="font-medium">Incidents</span>
<p className="text-white/70 mt-1">
Click on incident markers to view details about specific crime reports.
</p>
</div>
</div>
)}
</CardContent>
</Card>
</SidebarSection>

View File

@ -1,5 +1,5 @@
import React from 'react'
import { Activity, Calendar, CheckCircle, AlertTriangle, LineChart, PieChart, FileText } from 'lucide-react'
import { Activity, Calendar, CheckCircle, AlertTriangle, LineChart, PieChart, FileText, BarChart2, TrendingUp, MapPin, Clock, AlertCircle, Info, ArrowRight, RefreshCw } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card"
import { Separator } from "@/app/_components/ui/separator"
import { cn } from "@/app/_lib/utils"
@ -9,19 +9,275 @@ import { StatCard } from "../components/stat-card"
import { CrimeTypeCard, ICrimeTypeCardProps } from "../components/crime-type-card"
import { ICrimeAnalytics } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics'
import { MONTHS } from '@/app/_utils/const/common'
import { ICrimes } from '@/app/_utils/types/crimes'
import { Button } from "@/app/_components/ui/button"
interface ISidebarStatisticsTabProps {
crimeStats: ICrimeAnalytics
selectedMonth?: number | "all"
selectedYear: number
sourceType?: string
crimes?: ICrimes[]
}
// Component for rendering bar chart for monthly trends
const MonthlyTrendChart = ({ monthlyData = Array(12).fill(0) }: { monthlyData: number[] }) => (
<div className="h-32 flex items-end gap-1 mt-2">
{monthlyData.map((count: number, i: number) => {
const maxCount = Math.max(...monthlyData);
const height = maxCount > 0 ? (count / maxCount) * 100 : 0;
return (
<div
key={i}
className="bg-gradient-to-t from-emerald-600 to-green-400 w-full rounded-t-md"
style={{
height: `${Math.max(5, height)}%`,
opacity: 0.7 + (i / 24)
}}
title={`${getMonthName(i + 1)}: ${count} incidents`}
/>
);
})}
</div>
);
// Component for rendering yearly trend chart
const YearlyTrendChart = ({ crimes }: { crimes: ICrimes[] }) => {
const yearCounts = React.useMemo(() => {
const counts = new Map<number, number>();
const years = Array.from(new Set(crimes.map(c => c.year))).filter((y): y is number => typeof y === "number").sort();
years.forEach(year => {
counts.set(year, 0);
});
crimes.forEach(crime => {
if (crime.year) {
const currentCount = counts.get(crime.year) || 0;
counts.set(crime.year, currentCount + (crime.number_of_crime || 0));
}
});
return counts;
}, [crimes]);
const years = Array.from(yearCounts.keys()).sort();
const values = years.map(year => yearCounts.get(year) || 0);
if (values.length === 0) {
return (
<div className="flex items-center justify-center h-32 text-white/50 text-sm">
No yearly data available
</div>
);
}
return (
<div className="mt-3">
<div className="h-32 flex flex-col justify-end">
<div className="flex justify-between items-end h-full">
{years.map((year, i) => {
const count = values[i];
const maxCount = Math.max(...values);
const height = maxCount > 0 ? (count / maxCount) * 100 : 0;
return (
<div key={i} className="flex flex-col items-center gap-1 w-full">
<div
className="bg-blue-500 w-8 rounded-t-md"
style={{
height: `${Math.max(5, height)}%`,
opacity: 0.6 + (i / 10)
}}
title={`${year}: ${count} incidents`}
/>
<span className="text-[10px] text-white/60">{year}</span>
</div>
);
})}
</div>
</div>
</div>
);
};
// Component for district distribution chart
const DistrictDistribution = ({ crimes, sourceType }: { crimes: ICrimes[], sourceType?: string }) => {
const districtData = React.useMemo(() => {
const districtCounts = new Map<string, { name: string, count: number }>();
crimes.forEach(crime => {
if (crime.district_id && crime.districts?.name) {
const districtId = crime.district_id;
const districtName = crime.districts.name;
const count = crime.number_of_crime || 0;
if (districtCounts.has(districtId)) {
const current = districtCounts.get(districtId)!;
districtCounts.set(districtId, {
name: districtName,
count: current.count + count
});
} else {
districtCounts.set(districtId, {
name: districtName,
count: count
});
}
}
});
const sortedDistricts = Array.from(districtCounts.values())
.sort((a, b) => b.count - a.count)
.slice(0, 5);
const totalIncidents = sortedDistricts.reduce((sum, district) => sum + district.count, 0);
return sortedDistricts.map(district => ({
name: district.name,
count: district.count,
percentage: Math.round((district.count / totalIncidents) * 100)
}));
}, [crimes]);
if (districtData.length === 0) {
return (
<Card className="bg-gradient-to-r from-indigo-900/30 to-indigo-800/20 border border-indigo-900/20">
<CardHeader className="p-3 pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<MapPin className="h-4 w-4 text-indigo-400" />
Top Districts
</CardTitle>
<CardDescription className="text-xs text-white/60">Crime distribution by area</CardDescription>
</CardHeader>
<CardContent className="p-3 pt-2 text-center text-white/50 text-sm">
No district data available
</CardContent>
</Card>
);
}
return (
<Card className="bg-gradient-to-r from-indigo-900/30 to-indigo-800/20 border border-indigo-900/20">
<CardHeader className="p-3 pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<MapPin className="h-4 w-4 text-indigo-400" />
Top Districts
</CardTitle>
<CardDescription className="text-xs text-white/60">Crime distribution by area</CardDescription>
</CardHeader>
<CardContent className="p-3 pt-2">
<div className="space-y-2 mt-2">
{districtData.map((district, index) => (
<div key={index} className="flex flex-col gap-1">
<div className="flex justify-between text-xs">
<span>{district.name}</span>
<span className="font-medium">{district.count} incidents</span>
</div>
<div className="h-1.5 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full"
style={{ width: `${district.percentage}%` }}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
};
// Component for time of day distribution
const TimeOfDayDistribution = ({ crimes, sourceType }: { crimes: ICrimes[], sourceType?: string }) => {
const timeData = React.useMemo(() => {
const periods = [
{ name: "Morning (6AM-12PM)", start: 6, end: 11, color: "from-yellow-400 to-yellow-300" },
{ name: "Afternoon (12PM-6PM)", start: 12, end: 17, color: "from-orange-500 to-orange-400" },
{ name: "Evening (6PM-12AM)", start: 18, end: 23, color: "from-blue-600 to-blue-500" },
{ name: "Night (12AM-6AM)", start: 0, end: 5, color: "from-gray-800 to-gray-700" },
];
const counts = periods.map(p => ({
period: p.name,
count: 0,
percentage: 0,
color: p.color
}));
let totalCounted = 0;
crimes.forEach(crime => {
if (!crime.crime_incidents) return;
crime.crime_incidents.forEach((incident) => {
if (incident.timestamp) {
const date = new Date(incident.timestamp);
const hour = date.getHours();
for (let i = 0; i < periods.length; i++) {
if (hour >= periods[i].start && hour <= periods[i].end) {
counts[i].count++;
totalCounted++;
break;
}
}
}
});
});
if (totalCounted > 0) {
counts.forEach(item => {
item.percentage = Math.round((item.count / totalCounted) * 100);
});
}
if (totalCounted === 0) {
counts[0].percentage = 15;
counts[1].percentage = 25;
counts[2].percentage = 40;
counts[3].percentage = 20;
}
return counts;
}, [crimes]);
return (
<Card className="bg-gradient-to-r from-purple-900/30 to-purple-800/20 border border-purple-900/20">
<CardHeader className="p-3 pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Clock className="h-4 w-4 text-purple-400" />
Time of Day
</CardTitle>
<CardDescription className="text-xs text-white/60">When incidents occur</CardDescription>
</CardHeader>
<CardContent className="p-3 pt-2">
<div className="h-8 rounded-lg overflow-hidden flex mt-2">
{timeData.map((time, index) => (
<div
key={index}
className={`bg-gradient-to-r ${time.color} h-full`}
style={{ width: `${time.percentage}%` }}
title={`${time.period}: ${time.percentage}%`}
/>
))}
</div>
<div className="flex justify-between text-xs mt-1 text-white/70">
{timeData.map((time, index) => (
<div key={index} className="flex flex-col items-center">
<div className={`w-3 h-3 rounded-full bg-gradient-to-r ${time.color}`} />
<span className="text-[9px] mt-0.5">{time.percentage}%</span>
</div>
))}
</div>
</CardContent>
</Card>
);
};
export function SidebarStatisticsTab({
crimeStats,
selectedMonth = "all",
selectedYear
}: ISidebarStatisticsTabProps) {
selectedYear,
sourceType = "cbt",
crimes = []
}: ISidebarStatisticsTabProps & { crimes?: ICrimes[] }) {
const topCategories = crimeStats.categoryCounts ?
Object.entries(crimeStats.categoryCounts)
.sort((a, b) => b[1] - a[1])
@ -31,8 +287,64 @@ export function SidebarStatisticsTab({
return { type, count, percentage }
}) : []
// If source type is CBU, display warning instead of regular content
if (sourceType === "cbu") {
return (
<Card className="bg-gradient-to-r from-emerald-900/20 to-emerald-800/10 border border-emerald-500/30">
<CardContent className="p-6 flex flex-col items-center text-center">
<div className="bg-emerald-500/20 rounded-full p-3 mb-3">
<AlertTriangle className="h-8 w-8 text-emerald-400" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Limited Data View</h3>
<p className="text-white/80 mb-4">
The CBU data source only provides aggregated statistics without detailed incident information.
</p>
<div className="bg-black/20 rounded-lg p-3 w-full mb-4">
<div className="flex items-center justify-between mb-2">
<span className="text-white/60 text-sm">Current Data Source:</span>
<span className="font-medium text-emerald-400 text-sm">CBU</span>
</div>
<div className="flex items-center justify-between">
<span className="text-white/60 text-sm">Recommended:</span>
<span className="font-medium text-blue-400 text-sm">CBT</span>
</div>
</div>
<p className="text-white/70 text-sm mb-5">
To view detailed incident reports, individual crime records, and location-specific information, please switch to the CBT data source.
</p>
<div className="flex items-center gap-2 text-sm">
<Button
variant="outline"
size="sm"
className="border-emerald-500/50 hover:bg-emerald-500/20 text-emerald-300"
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Change Data Source
</Button>
</div>
<div className="w-full mt-6 pt-3 border-t border-emerald-500/20">
<p className="text-xs text-white/60">
The CBU (Crime By Unit) data provides insights at the district level, while CBT (Crime By Type) includes detailed incident-level information.
</p>
</div>
</CardContent>
</Card>
);
}
return (
<>
<Card className="bg-gradient-to-r from-sidebar-primary/30 to-sidebar-primary/20 border border-sidebar-primary/20 overflow-hidden">
<CardHeader className="p-3 pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
@ -42,27 +354,7 @@ export function SidebarStatisticsTab({
<CardDescription className="text-xs text-white/60">{selectedYear}</CardDescription>
</CardHeader>
<CardContent className="p-3">
<div className="h-32 flex items-end gap-1 mt-2">
{crimeStats.incidentsByMonth.map((count: number, i: number) => {
const maxCount = Math.max(...crimeStats.incidentsByMonth)
const height = maxCount > 0 ? (count / maxCount) * 100 : 0
return (
<div
key={i}
className={cn(
"bg-gradient-to-t from-emerald-600 to-green-400 w-full rounded-t-md",
selectedMonth !== 'all' && i + 1 === Number(selectedMonth) ? "from-amber-500 to-amber-400" : ""
)}
style={{
height: `${Math.max(5, height)}%`,
opacity: 0.7 + (i / 24)
}}
title={`${getMonthName(i + 1)}: ${count} incidents`}
/>
)
})}
</div>
<MonthlyTrendChart monthlyData={crimeStats.incidentsByMonth} />
<div className="flex justify-between mt-2 text-[10px] text-white/60">
{MONTHS.map((month, i) => (
<span key={i} className={cn("w-1/12 text-center", selectedMonth !== 'all' && i + 1 === Number(selectedMonth) ? "text-amber-400" : "")}>
@ -109,32 +401,57 @@ export function SidebarStatisticsTab({
</div>
</SidebarSection>
<Card className="bg-gradient-to-r from-blue-900/30 to-blue-800/20 border border-blue-900/20 overflow-hidden">
<CardHeader className="p-3 pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<BarChart2 className="h-4 w-4 text-blue-400" />
Yearly Trends
</CardTitle>
<CardDescription className="text-xs text-white/60">Historical Overview</CardDescription>
</CardHeader>
<CardContent className="p-3">
<YearlyTrendChart crimes={crimes} />
</CardContent>
</Card>
<Separator className="bg-white/20 my-4" />
<SidebarSection title="Most Common Crimes" icon={<PieChart className="h-4 w-4 text-amber-400" />}>
<div className="space-y-3">
{topCategories.length > 0 ? (
topCategories.map((category: ICrimeTypeCardProps) => (
<CrimeTypeCard
key={category.type}
type={category.type}
count={category.count}
percentage={category.percentage}
/>
))
) : (
<Card className="bg-white/5 border-0 text-white shadow-none">
<CardContent className="p-4 text-center">
<div className="flex flex-col items-center gap-2">
<FileText className="h-6 w-6 text-white/40" />
<p className="text-sm text-white/70">No crime data available</p>
<p className="text-xs text-white/50">Try selecting a different time period</p>
</div>
</CardContent>
</Card>
)}
</div>
</SidebarSection>
<TimeOfDayDistribution crimes={crimes} sourceType={sourceType} />
<div className="mt-4">
<DistrictDistribution crimes={crimes} sourceType={sourceType} />
</div>
{sourceType === "cbt" && (
<>
<Separator className="bg-white/20 my-4" />
<SidebarSection title="Most Common Crimes" icon={<PieChart className="h-4 w-4 text-amber-400" />}>
<div className="space-y-3">
{topCategories.length > 0 ? (
topCategories.map((category: ICrimeTypeCardProps) => (
<CrimeTypeCard
key={category.type}
type={category.type}
count={category.count}
percentage={category.percentage}
/>
))
) : (
<Card className="bg-white/5 border-0 text-white shadow-none">
<CardContent className="p-4 text-center">
<div className="flex flex-col items-center gap-2">
<FileText className="h-6 w-6 text-white/40" />
<p className="text-sm text-white/70">No crime data available</p>
<p className="text-xs text-white/50">Try selecting a different time period</p>
</div>
</CardContent>
</Card>
)}
</div>
</SidebarSection>
</>
)}
</>
)
}

View File

@ -0,0 +1,69 @@
"use client"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@/app/_components/ui/select"
import { cn } from "@/app/_lib/utils"
import { useEffect, useRef, useState } from "react"
import { Skeleton } from "../../ui/skeleton"
interface SourceTypeSelectorProps {
selectedSourceType: string
onSourceTypeChange: (sourceType: string) => void
availableSourceTypes: string[]
className?: string
isLoading?: boolean
}
export default function SourceTypeSelector({
selectedSourceType,
onSourceTypeChange,
availableSourceTypes,
className,
isLoading = false,
}: SourceTypeSelectorProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [isClient, setIsClient] = useState(false)
useEffect(() => {
// This will ensure that the document is only used in the client-side context
setIsClient(true)
}, [])
const container = isClient ? document.getElementById("root") : null
return (
<div ref={containerRef} className="mapboxgl-category-selector">
{isLoading ? (
<div className="flex items-center justify-center h-8">
<Skeleton className="h-full w-full rounded-md" />
</div>
) : (
<Select
value={selectedSourceType}
onValueChange={(value) => onSourceTypeChange(value)}
>
<SelectTrigger className={className}>
<SelectValue placeholder="Crime Category" />
</SelectTrigger>
<SelectContent
container={containerRef.current || container || undefined}
style={{ zIndex: 2000 }}
className={`${className}`}
>
{availableSourceTypes.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}

View File

@ -3,19 +3,19 @@
import { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
import { Popover, PopoverContent, PopoverTrigger } from "@/app/_components/ui/popover"
import { ChevronDown, Layers, Siren } from "lucide-react"
import { ChevronDown, Siren } from "lucide-react"
import { IconMessage } from "@tabler/icons-react"
import { useEffect, useRef, useState } from "react"
import { ITooltips } from "./tooltips"
import type { ITooltips } from "./tooltips"
import MonthSelector from "../month-selector"
import YearSelector from "../year-selector"
import CategorySelector from "../category-selector"
import SourceTypeSelector from "../source-type-selector"
// Define the additional tools and features
const additionalTooltips = [
{ id: "reports" as ITooltips, icon: <IconMessage size={20} />, label: "Police Report" },
{ id: "layers" as ITooltips, icon: <Layers size={20} />, label: "Map Layers" },
{ id: "alerts" as ITooltips, icon: <Siren size={20} />, label: "Active Alerts" },
]
@ -28,7 +28,10 @@ interface AdditionalTooltipsProps {
setSelectedMonth: (month: number | "all") => void
selectedCategory: string | "all"
setSelectedCategory: (category: string | "all") => void
selectedSourceType: string
setSelectedSourceType: (sourceType: string) => void
availableYears?: (number | null)[]
availableSourceTypes?: string[]
categories?: string[]
panicButtonTriggered?: boolean
}
@ -42,7 +45,10 @@ export default function AdditionalTooltips({
setSelectedMonth,
selectedCategory,
setSelectedCategory,
availableYears = [2022, 2023, 2024],
selectedSourceType = "cbu",
setSelectedSourceType,
availableYears = [],
availableSourceTypes = [],
categories = [],
panicButtonTriggered = false,
}: AdditionalTooltipsProps) {
@ -54,110 +60,138 @@ export default function AdditionalTooltips({
useEffect(() => {
if (panicButtonTriggered && activeControl !== "alerts" && onControlChange) {
onControlChange("alerts");
}
}, [panicButtonTriggered, activeControl, onControlChange]);
onControlChange("alerts")
}
}, [panicButtonTriggered, activeControl, onControlChange])
useEffect(() => {
setIsClient(true);
}, []);
setIsClient(true)
}, [])
const isControlDisabled = (controlId: ITooltips) => {
// When source type is CBU, disable all controls except for layers
return selectedSourceType === "cbu" && controlId !== "layers"
}
return (
<>
<div ref={containerRef} className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider>
{additionalTooltips.map((control) => (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="medium"
className={`h-8 w-8 rounded-md ${activeControl === control.id
{additionalTooltips.map((control) => {
const isButtonDisabled = isControlDisabled(control.id)
return (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="medium"
className={`h-8 w-8 rounded-md ${isButtonDisabled
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
: activeControl === control.id
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
: "text-white hover:bg-emerald-500/90 hover:text-background"
} ${control.id === "alerts" && panicButtonTriggered ? "animate-pulse ring-2 ring-red-500" : ""}`}
onClick={() => onControlChange?.(control.id)}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{control.label}</p>
</TooltipContent>
</Tooltip>
))}
} ${control.id === "alerts" && panicButtonTriggered ? "animate-pulse ring-2 ring-red-500" : ""}`}
onClick={() => onControlChange?.(control.id)}
disabled={isButtonDisabled}
aria-disabled={isButtonDisabled}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{isButtonDisabled ? "Not available for CBU data" : control.label}</p>
</TooltipContent>
</Tooltip>
)
})}
<Tooltip>
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-white hover:bg-emerald-500/90 hover:text-background"
onClick={() => setShowSelectors(!showSelectors)}
>
<ChevronDown size={20} />
<span className="sr-only">Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent
container={containerRef.current || container || undefined}
className="w-auto p-3 bg-black/90 border-gray-700 text-white"
align="end"
style={{ zIndex: 2000 }}
>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="text-xs w-16">Year:</span>
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
className="w-[180px]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs w-16">Month:</span>
<MonthSelector
selectedMonth={selectedMonth}
onMonthChange={setSelectedMonth}
className="w-[180px]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs w-16">Category:</span>
<CategorySelector
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
className="w-[180px]"
/>
</div>
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
</div>
<Tooltip>
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-white hover:bg-emerald-500/90 hover:text-background"
onClick={() => setShowSelectors(!showSelectors)}
>
<ChevronDown size={20} />
<span className="sr-only">Filters</span>
</Button>
</PopoverTrigger>
<PopoverContent
container={containerRef.current || container || undefined}
className="w-auto p-3 bg-black/90 border-gray-700 text-white"
align="end"
style={{ zIndex: 2000 }}
>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<span className="text-xs w-16">Source:</span>
<SourceTypeSelector
availableSourceTypes={availableSourceTypes}
selectedSourceType={selectedSourceType}
onSourceTypeChange={setSelectedSourceType}
className="w-[180px]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs w-16">Year:</span>
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
className="w-[180px]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs w-16">Month:</span>
<MonthSelector
selectedMonth={selectedMonth}
onMonthChange={setSelectedMonth}
className="w-[180px]"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs w-16">Category:</span>
<CategorySelector
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
className="w-[180px]"
/>
</div>
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
</div>
{showSelectors && (
<div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden">
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
className="w-[100px]"
/>
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[100px]" />
<CategorySelector
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
className="w-[100px]"
/>
</div>
)}
</>
)
{showSelectors && (
<div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden">
<SourceTypeSelector
availableSourceTypes={availableSourceTypes}
selectedSourceType={selectedSourceType}
onSourceTypeChange={setSelectedSourceType}
className="w-[80px]"
/>
<YearSelector
availableYears={availableYears}
selectedYear={selectedYear}
onYearChange={setSelectedYear}
className="w-[80px]"
/>
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[80px]" />
<CategorySelector
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
className="w-[80px]"
/>
</div>
)}
</>
)
}

View File

@ -2,17 +2,16 @@
import { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
import { AlertTriangle, BarChart2, Building, Car, ChartScatter, Clock, Thermometer, Shield, Users } from "lucide-react"
import { ITooltips } from "./tooltips"
import { IconBubble, IconChartBubble, IconClock } from "@tabler/icons-react"
import { AlertTriangle, Building, Car, Thermometer } from "lucide-react"
import type { ITooltips } from "./tooltips"
import { IconChartBubble, IconClock } from "@tabler/icons-react"
// Define the primary crime data controls
const crimeTooltips = [
{ id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" },
{ id: "heatmap" as ITooltips, icon: <Thermometer size={20} />, label: "Density Heatmap" },
{ id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" },
{ id: "units" as ITooltips, icon: <Building size={20} />, label: "Police Units" },
{ id: "units" as ITooltips, icon: <Building size={20} />, label: "Police Units" },
{ id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" },
{ id: "timeline" as ITooltips, icon: <IconClock size={20} />, label: "Time Analysis" },
]
@ -20,43 +19,61 @@ const crimeTooltips = [
interface CrimeTooltipsProps {
activeControl?: string
onControlChange?: (controlId: ITooltips) => void
sourceType?: string
}
export default function CrimeTooltips({ activeControl, onControlChange }: CrimeTooltipsProps) {
export default function CrimeTooltips({ activeControl, onControlChange, sourceType = "cbt" }: CrimeTooltipsProps) {
const handleControlClick = (controlId: ITooltips) => {
console.log("Clicked control:", controlId);
// Force the value to be set when clicking
if (onControlChange) {
onControlChange(controlId);
console.log("Control changed to:", controlId);
}
};
// If control is disabled, don't do anything
if (isDisabled(controlId)) {
return
}
// Force the value to be set when clicking
if (onControlChange) {
onControlChange(controlId)
console.log("Control changed to:", controlId)
}
}
// Determine which controls should be disabled based on source type
const isDisabled = (controlId: ITooltips) => {
return sourceType === "cbu" && controlId !== "clusters"
}
return (
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider>
{crimeTooltips.map((control) => (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="medium"
className={`h-8 w-8 rounded-md ${activeControl === control.id
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
: "text-white hover:bg-emerald-500/90 hover:text-background"
}`}
onClick={() => handleControlClick(control.id)}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{control.label}</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
)
{crimeTooltips.map((control) => {
const isButtonDisabled = isDisabled(control.id)
return (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="medium"
className={`h-8 w-8 rounded-md ${isButtonDisabled
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
: activeControl === control.id
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
: "text-white hover:bg-emerald-500/90 hover:text-background"
}`}
onClick={() => handleControlClick(control.id)}
disabled={isButtonDisabled}
aria-disabled={isButtonDisabled}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{isButtonDisabled ? "Not available for CBU data" : control.label}</p>
</TooltipContent>
</Tooltip>
)
})}
</TooltipProvider>
</div>
)
}

View File

@ -2,15 +2,25 @@
import { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
import { Search, XCircle, Info, ExternalLink, Calendar, MapPin, MessageSquare, FileText, Map, FolderOpen } from 'lucide-react'
import {
Search,
XCircle,
Info,
ExternalLink,
Calendar,
MapPin,
MessageSquare,
FileText,
Map,
FolderOpen,
} from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "framer-motion"
import ActionSearchBar from "@/app/_components/action-search-bar"
import { Card } from "@/app/_components/ui/card"
import { format } from 'date-fns'
import { ITooltips } from "./tooltips"
import { $Enums } from "@prisma/client"
import { format } from "date-fns"
import type { ITooltips } from "./tooltips"
// Define types based on the crime data structure
interface ICrimeIncident {
@ -19,10 +29,10 @@ interface ICrimeIncident {
description: string
status: string
locations: {
address: string;
longitude: number;
latitude: number;
},
address: string
longitude: number
latitude: number
}
crime_categories: {
id: string
name: string
@ -88,9 +98,15 @@ interface SearchTooltipProps {
onControlChange?: (controlId: ITooltips) => void
activeControl?: string
crimes?: ICrime[]
sourceType?: string
}
export default function SearchTooltip({ onControlChange, activeControl, crimes = [] }: SearchTooltipProps) {
export default function SearchTooltip({
onControlChange,
activeControl,
crimes = [],
sourceType = "cbt",
}: SearchTooltipProps) {
const [showSearch, setShowSearch] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const [selectedSearchType, setSelectedSearchType] = useState<string | null>(null)
@ -100,237 +116,244 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
const [selectedSuggestion, setSelectedSuggestion] = useState<ICrimeIncident | null>(null)
const [showInfoBox, setShowInfoBox] = useState(false)
// Check if search is disabled based on source type
const isSearchDisabled = sourceType === "cbu"
// Limit results to prevent performance issues
const MAX_RESULTS = 50;
const MAX_RESULTS = 50
// Extract all incidents from crimes data
const allIncidents = crimes.flatMap(crime =>
crime.crime_incidents.map(incident => ({
const allIncidents = crimes.flatMap((crime) =>
crime.crime_incidents.map((incident) => ({
...incident,
district: crime.districts?.name || '',
year: crime.year,
month: crime.month
}))
)
district: crime.districts?.name || "",
year: crime.year,
month: crime.month,
})),
)
useEffect(() => {
if (showSearch && searchInputRef.current) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 100);
}
}, [showSearch]);
searchInputRef.current?.focus()
}, 100)
}
}, [showSearch])
const handleSearchTypeSelect = (actionId: string) => {
const selectedAction = ACTIONS.find(action => action.id === actionId);
if (selectedAction) {
setSelectedSearchType(actionId);
const selectedAction = ACTIONS.find((action) => action.id === actionId)
if (selectedAction) {
setSelectedSearchType(actionId)
const prefix = selectedAction.prefix || "";
setSearchValue(prefix);
setIsInputValid(true);
const prefix = selectedAction.prefix || ""
setSearchValue(prefix)
setIsInputValid(true)
// Initial suggestions based on the selected search type
let initialSuggestions: ICrimeIncident[] = [];
// Initial suggestions based on the selected search type
let initialSuggestions: ICrimeIncident[] = []
if (actionId === 'incident_id') {
initialSuggestions = allIncidents.slice(0, MAX_RESULTS); // Limit to 50 results initially
} else if (actionId === 'description' || actionId === 'locations.address') {
initialSuggestions = allIncidents.slice(0, MAX_RESULTS);
}
// Set suggestions in the next tick
setTimeout(() => {
setSuggestions(initialSuggestions);
}, 0);
// Focus and position cursor after prefix
setTimeout(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
searchInputRef.current.selectionStart = prefix.length;
searchInputRef.current.selectionEnd = prefix.length;
}
}, 50);
if (actionId === "incident_id") {
initialSuggestions = allIncidents.slice(0, MAX_RESULTS) // Limit to 50 results initially
} else if (actionId === "description" || actionId === "locations.address") {
initialSuggestions = allIncidents.slice(0, MAX_RESULTS)
}
}
// Set suggestions in the next tick
setTimeout(() => {
setSuggestions(initialSuggestions)
}, 0)
// Focus and position cursor after prefix
setTimeout(() => {
if (searchInputRef.current) {
searchInputRef.current.focus()
searchInputRef.current.selectionStart = prefix.length
searchInputRef.current.selectionEnd = prefix.length
}
}, 50)
}
}
// Filter suggestions based on search type and search text
const filterSuggestions = (searchType: string, searchText: string): ICrimeIncident[] => {
let filtered: ICrimeIncident[] = [];
let filtered: ICrimeIncident[] = []
if (searchType === 'incident_id') {
if (!searchText || searchText === 'CI-') {
filtered = allIncidents.slice(0, MAX_RESULTS);
} else {
filtered = allIncidents.filter(item =>
item.id.toLowerCase().includes(searchText.toLowerCase())
).slice(0, MAX_RESULTS);
}
}
else if (searchType === 'description') {
if (!searchText) {
filtered = allIncidents.slice(0, MAX_RESULTS);
} else {
filtered = allIncidents.filter(item =>
item.description.toLowerCase().includes(searchText.toLowerCase())
).slice(0, MAX_RESULTS);
}
}
else if (searchType === 'locations.address') {
if (!searchText) {
filtered = allIncidents.slice(0, MAX_RESULTS);
} else {
filtered = allIncidents.filter(item =>
item.locations.address && item.locations.address.toLowerCase().includes(searchText.toLowerCase())
).slice(0, MAX_RESULTS);
}
}
else if (searchType === 'coordinates') {
if (!searchText) {
filtered = allIncidents.filter(item => item.locations.latitude !== undefined && item.locations.longitude !== undefined)
.slice(0, MAX_RESULTS);
} else {
// For coordinates, we'd typically do a proximity search
// This is a simple implementation for demo purposes
filtered = allIncidents.filter(item =>
item.locations.latitude !== undefined &&
item.locations.longitude !== undefined &&
`${item.locations.latitude}, ${item.locations.longitude}`.includes(searchText)
).slice(0, MAX_RESULTS);
}
if (searchType === "incident_id") {
if (!searchText || searchText === "CI-") {
filtered = allIncidents.slice(0, MAX_RESULTS)
} else {
filtered = allIncidents
.filter((item) => item.id.toLowerCase().includes(searchText.toLowerCase()))
.slice(0, MAX_RESULTS)
}
} else if (searchType === "description") {
if (!searchText) {
filtered = allIncidents.slice(0, MAX_RESULTS)
} else {
filtered = allIncidents
.filter((item) => item.description.toLowerCase().includes(searchText.toLowerCase()))
.slice(0, MAX_RESULTS)
}
} else if (searchType === "locations.address") {
if (!searchText) {
filtered = allIncidents.slice(0, MAX_RESULTS)
} else {
filtered = allIncidents
.filter(
(item) => item.locations.address && item.locations.address.toLowerCase().includes(searchText.toLowerCase()),
)
.slice(0, MAX_RESULTS)
}
} else if (searchType === "coordinates") {
if (!searchText) {
filtered = allIncidents
.filter((item) => item.locations.latitude !== undefined && item.locations.longitude !== undefined)
.slice(0, MAX_RESULTS)
} else {
// For coordinates, we'd typically do a proximity search
// This is a simple implementation for demo purposes
filtered = allIncidents
.filter(
(item) =>
item.locations.latitude !== undefined &&
item.locations.longitude !== undefined &&
`${item.locations.latitude}, ${item.locations.longitude}`.includes(searchText),
)
.slice(0, MAX_RESULTS)
}
}
return filtered;
};
return filtered
}
const handleSearchChange = (value: string) => {
const currentSearchType = selectedSearchType ?
ACTIONS.find(action => action.id === selectedSearchType) : null;
const currentSearchType = selectedSearchType ? ACTIONS.find((action) => action.id === selectedSearchType) : null
if (currentSearchType?.prefix && currentSearchType.prefix.length > 0) {
if (!value.startsWith(currentSearchType.prefix)) {
value = currentSearchType.prefix;
}
if (currentSearchType?.prefix && currentSearchType.prefix.length > 0) {
if (!value.startsWith(currentSearchType.prefix)) {
value = currentSearchType.prefix
}
setSearchValue(value);
if (currentSearchType?.regex) {
if (!value || value === currentSearchType.prefix) {
setIsInputValid(true);
} else {
setIsInputValid(currentSearchType.regex.test(value));
}
} else {
setIsInputValid(true);
}
if (!selectedSearchType) {
setSuggestions([]);
return;
}
// Filter suggestions based on search input
setSuggestions(filterSuggestions(selectedSearchType, value));
}
setSearchValue(value)
if (currentSearchType?.regex) {
if (!value || value === currentSearchType.prefix) {
setIsInputValid(true)
} else {
setIsInputValid(currentSearchType.regex.test(value))
}
} else {
setIsInputValid(true)
}
if (!selectedSearchType) {
setSuggestions([])
return
}
// Filter suggestions based on search input
setSuggestions(filterSuggestions(selectedSearchType, value))
}
const handleClearSearchType = () => {
setSelectedSearchType(null);
setSearchValue("");
setSuggestions([]);
if (searchInputRef.current) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 50);
}
};
setSelectedSearchType(null)
setSearchValue("")
setSuggestions([])
if (searchInputRef.current) {
setTimeout(() => {
searchInputRef.current?.focus()
}, 50)
}
}
const handleSuggestionSelect = (incident: ICrimeIncident) => {
setSearchValue(incident.id);
setSuggestions([]);
setSelectedSuggestion(incident);
setShowInfoBox(true);
};
setSearchValue(incident.id)
setSuggestions([])
setSelectedSuggestion(incident)
setShowInfoBox(true)
}
const handleFlyToIncident = () => {
if (!selectedSuggestion || !selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude) return;
if (!selectedSuggestion || !selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude) return
// Dispatch mapbox_fly_to event to the main map canvas only
const flyToMapEvent = new CustomEvent('mapbox_fly_to', {
// Dispatch mapbox_fly_to event to the main map canvas only
const flyToMapEvent = new CustomEvent("mapbox_fly_to", {
detail: {
longitude: selectedSuggestion.locations.longitude,
latitude: selectedSuggestion.locations.latitude,
zoom: 15,
bearing: 0,
pitch: 45,
duration: 2000,
},
bubbles: true,
})
// Find the main map canvas and dispatch event there
const mapCanvas = document.querySelector(".mapboxgl-canvas")
if (mapCanvas) {
mapCanvas.dispatchEvent(flyToMapEvent)
}
// Wait for the fly animation to complete before showing the popup
setTimeout(() => {
// Then trigger the incident_click event to show the popup
const incidentEvent = new CustomEvent("incident_click", {
detail: {
id: selectedSuggestion.id,
longitude: selectedSuggestion.locations.longitude,
latitude: selectedSuggestion.locations.latitude,
zoom: 15,
bearing: 0,
pitch: 45,
duration: 2000,
},
bubbles: true
});
description: selectedSuggestion.description,
status: selectedSuggestion.status,
timestamp: selectedSuggestion.timestamp,
crime_categories: selectedSuggestion.crime_categories,
},
bubbles: true,
})
// Find the main map canvas and dispatch event there
const mapCanvas = document.querySelector('.mapboxgl-canvas');
if (mapCanvas) {
mapCanvas.dispatchEvent(flyToMapEvent);
}
document.dispatchEvent(incidentEvent)
}, 2100) // Slightly longer than the fly animation duration
// Wait for the fly animation to complete before showing the popup
setTimeout(() => {
// Then trigger the incident_click event to show the popup
const incidentEvent = new CustomEvent('incident_click', {
detail: {
id: selectedSuggestion.id,
longitude: selectedSuggestion.locations.longitude,
latitude: selectedSuggestion.locations.latitude,
description: selectedSuggestion.description,
status: selectedSuggestion.status,
timestamp: selectedSuggestion.timestamp,
crime_categories: selectedSuggestion.crime_categories
},
bubbles: true
});
document.dispatchEvent(incidentEvent);
}, 2100); // Slightly longer than the fly animation duration
setShowInfoBox(false);
setSelectedSuggestion(null);
toggleSearch();
};
setShowInfoBox(false)
setSelectedSuggestion(null)
toggleSearch()
}
const handleCloseInfoBox = () => {
setShowInfoBox(false);
setSelectedSuggestion(null);
setShowInfoBox(false)
setSelectedSuggestion(null)
// Restore original suggestions
if (selectedSearchType) {
const initialSuggestions = filterSuggestions(selectedSearchType, searchValue);
setSuggestions(initialSuggestions);
}
};
// Restore original suggestions
if (selectedSearchType) {
const initialSuggestions = filterSuggestions(selectedSearchType, searchValue)
setSuggestions(initialSuggestions)
}
}
const toggleSearch = () => {
setShowSearch(!showSearch)
if (!showSearch && onControlChange) {
onControlChange("search" as ITooltips)
setSelectedSearchType(null);
setSearchValue("");
setSuggestions([]);
}
}
if (isSearchDisabled) return
setShowSearch(!showSearch)
if (!showSearch && onControlChange) {
onControlChange("search" as ITooltips)
setSelectedSearchType(null)
setSearchValue("")
setSuggestions([])
}
}
// Format date for display
const formatIncidentDate = (incident: ICrimeIncident) => {
try {
if (incident.timestamp) {
return format(new Date(incident.timestamp), 'PPP p');
}
return 'N/A';
} catch (error) {
return 'Invalid date';
}
};
return format(new Date(incident.timestamp), "PPP p")
}
return "N/A"
} catch (error) {
return "Invalid date"
}
}
return (
<>
@ -341,223 +364,222 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
<Button
variant={showSearch ? "default" : "ghost"}
size="medium"
className={`h-8 w-8 rounded-md ${showSearch
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
: "text-white hover:bg-emerald-500/90 hover:text-background"
}`}
onClick={toggleSearch}
>
<Search size={20} />
<span className="sr-only">Search Incidents</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Search Incidents</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
className={`h-8 w-8 rounded-md ${isSearchDisabled
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
: showSearch
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
: "text-white hover:bg-emerald-500/90 hover:text-background"
}`}
onClick={toggleSearch}
disabled={isSearchDisabled}
aria-disabled={isSearchDisabled}
>
<Search size={20} />
<span className="sr-only">Search Incidents</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{isSearchDisabled ? "Not available for CBU data" : "Search Incidents"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<AnimatePresence>
{showSearch && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
onClick={toggleSearch}
/>
<AnimatePresence>
{showSearch && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
onClick={toggleSearch}
/>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="fixed top-1/4 left-1/4 transform -translate-x-1/4 -translate-y-1/4 z-50 w-full max-w-lg sm:max-w-xl md:max-w-3xl"
>
<div className="bg-background border border-border rounded-lg shadow-xl p-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-medium">Search Incidents</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full hover:bg-emerald-500/90 hover:text-background"
onClick={toggleSearch}
>
<XCircle size={18} />
</Button>
</div>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="fixed top-1/4 left-1/4 transform -translate-x-1/4 -translate-y-1/4 z-50 w-full max-w-lg sm:max-w-xl md:max-w-3xl"
>
<div className="bg-background border border-border rounded-lg shadow-xl p-4">
<div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-medium">Search Incidents</h3>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full hover:bg-emerald-500/90 hover:text-background"
onClick={toggleSearch}
>
<XCircle size={18} />
</Button>
</div>
{!showInfoBox ? (
<>
<ActionSearchBar
ref={searchInputRef}
autoFocus
isFloating
defaultActions={false}
actions={ACTIONS}
onActionSelect={handleSearchTypeSelect}
onClearAction={handleClearSearchType}
activeActionId={selectedSearchType}
value={searchValue}
onChange={handleSearchChange}
placeholder={selectedSearchType ?
ACTIONS.find(a => a.id === selectedSearchType)?.placeholder :
"Select a search type..."}
inputClassName={!isInputValid ?
"border-destructive focus-visible:ring-destructive bg-destructive/50" : ""}
/>
{!showInfoBox ? (
<>
<ActionSearchBar
ref={searchInputRef}
autoFocus
isFloating
defaultActions={false}
actions={ACTIONS}
onActionSelect={handleSearchTypeSelect}
onClearAction={handleClearSearchType}
activeActionId={selectedSearchType}
value={searchValue}
onChange={handleSearchChange}
placeholder={
selectedSearchType
? ACTIONS.find((a) => a.id === selectedSearchType)?.placeholder
: "Select a search type..."
}
inputClassName={
!isInputValid ? "border-destructive focus-visible:ring-destructive bg-destructive/50" : ""
}
/>
{!isInputValid && selectedSearchType && (
<div className="mt-1 text-xs text-destructive">
Invalid format. {ACTIONS.find(a => a.id === selectedSearchType)?.description}
</div>
)}
{!isInputValid && selectedSearchType && (
<div className="mt-1 text-xs text-destructive">
Invalid format. {ACTIONS.find((a) => a.id === selectedSearchType)?.description}
</div>
)}
{(suggestions.length > 0 && selectedSearchType) && (
<div className="mt-2 max-h-[300px] overflow-y-auto border border-border rounded-md bg-background/80 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
<div className="sticky top-0 bg-muted/70 backdrop-blur-sm px-3 py-2 border-b border-border">
<p className="text-xs text-muted-foreground">
{suggestions.length} results found
{suggestions.length === 50 && " (showing top 50)"}
</p>
</div>
<ul className="py-1">
{suggestions.map((incident, index) => (
<li
key={index}
className="px-3 py-2 hover:bg-muted cursor-pointer flex items-center justify-between"
onClick={() => handleSuggestionSelect(incident)}
>
<span className="font-medium">{incident.id}</span>
<div className="flex items-center gap-2">
{selectedSearchType === 'incident_id' ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.description}
</span>
) : selectedSearchType === 'coordinates' ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.locations.latitude}, {incident.locations.longitude} - {incident.description}
</span>
) : selectedSearchType === 'locations.address' ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.locations.address || 'N/A'}
</span>
) : (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.description}
</span>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 rounded-full hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
handleSuggestionSelect(incident);
}}
>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</li>
))}
</ul>
</div>
)}
{suggestions.length > 0 && selectedSearchType && (
<div className="mt-2 max-h-[300px] overflow-y-auto border border-border rounded-md bg-background/80 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent">
<div className="sticky top-0 bg-muted/70 backdrop-blur-sm px-3 py-2 border-b border-border">
<p className="text-xs text-muted-foreground">
{suggestions.length} results found
{suggestions.length === 50 && " (showing top 50)"}
</p>
</div>
<ul className="py-1">
{suggestions.map((incident, index) => (
<li
key={index}
className="px-3 py-2 hover:bg-muted cursor-pointer flex items-center justify-between"
onClick={() => handleSuggestionSelect(incident)}
>
<span className="font-medium">{incident.id}</span>
<div className="flex items-center gap-2">
{selectedSearchType === "incident_id" ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.description}
</span>
) : selectedSearchType === "coordinates" ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.locations.latitude}, {incident.locations.longitude} -{" "}
{incident.description}
</span>
) : selectedSearchType === "locations.address" ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.locations.address || "N/A"}
</span>
) : (
<span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.description}
</span>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 rounded-full hover:bg-muted"
onClick={(e) => {
e.stopPropagation()
handleSuggestionSelect(incident)
}}
>
<Info className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
</li>
))}
</ul>
</div>
)}
{selectedSearchType && searchValue.length > (ACTIONS.find(a => a.id === selectedSearchType)?.prefix?.length || 0) &&
suggestions.length === 0 && (
<div className="mt-2 p-3 border border-border rounded-md bg-background/80 text-center">
<p className="text-sm text-muted-foreground">No matching incidents found</p>
</div>
)}
{selectedSearchType &&
searchValue.length > (ACTIONS.find((a) => a.id === selectedSearchType)?.prefix?.length || 0) &&
suggestions.length === 0 && (
<div className="mt-2 p-3 border border-border rounded-md bg-background/80 text-center">
<p className="text-sm text-muted-foreground">No matching incidents found</p>
</div>
)}
<div className="mt-3 px-3 py-2 border-t border-border bg-muted/30 rounded-b-md">
<p className="flex items-center text-sm text-muted-foreground">
{selectedSearchType ? (
<>
<span className="mr-1">{ACTIONS.find(a => a.id === selectedSearchType)?.icon}</span>
<span>
{ACTIONS.find(a => a.id === selectedSearchType)?.description}
</span>
</>
) : (
<span>
Select a search type and enter your search criteria
</span>
)}
</p>
</div>
</>
) : (
<Card className="p-4 border border-border">
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-semibold">{selectedSuggestion?.id}</h3>
</div>
<div className="mt-3 px-3 py-2 border-t border-border bg-muted/30 rounded-b-md">
<p className="flex items-center text-sm text-muted-foreground">
{selectedSearchType ? (
<>
<span className="mr-1">{ACTIONS.find((a) => a.id === selectedSearchType)?.icon}</span>
<span>{ACTIONS.find((a) => a.id === selectedSearchType)?.description}</span>
</>
) : (
<span>Select a search type and enter your search criteria</span>
)}
</p>
</div>
</>
) : (
<Card className="p-4 border border-border">
<div className="flex justify-between items-start mb-4">
<h3 className="text-lg font-semibold">{selectedSuggestion?.id}</h3>
</div>
{selectedSuggestion && (
<div className="space-y-3">
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<Info className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{selectedSuggestion.description}</p>
</div>
{selectedSuggestion && (
<div className="space-y-3">
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<Info className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{selectedSuggestion.description}</p>
</div>
{selectedSuggestion.timestamp && (
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<Calendar className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">
{formatIncidentDate(selectedSuggestion)}
</p>
</div>
)}
{selectedSuggestion.timestamp && (
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<Calendar className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{formatIncidentDate(selectedSuggestion)}</p>
</div>
)}
{selectedSuggestion.locations.address && (
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<MapPin className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{selectedSuggestion.locations.address}</p>
</div>
)}
{selectedSuggestion.locations.address && (
<div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<MapPin className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{selectedSuggestion.locations.address}</p>
</div>
)}
<div className="grid grid-cols-2 gap-2 mt-2">
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground font-medium">Category</p>
<p className="text-sm">{selectedSuggestion.crime_categories?.name || 'N/A'}</p>
</div>
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground font-medium">Status</p>
<p className="text-sm">{selectedSuggestion.status || 'N/A'}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2 mt-2">
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground font-medium">Category</p>
<p className="text-sm">{selectedSuggestion.crime_categories?.name || "N/A"}</p>
</div>
<div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground font-medium">Status</p>
<p className="text-sm">{selectedSuggestion.status || "N/A"}</p>
</div>
</div>
<div className="flex justify-between items-center pt-3 border-t border-border mt-3">
<Button
variant="outline"
size="sm"
onClick={handleCloseInfoBox}
>
Close
</Button>
<Button
variant="default"
size="sm"
onClick={handleFlyToIncident}
disabled={!selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude}
className="flex items-center gap-2"
>
<span>Fly to Incident</span>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</Card>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
</>
)
<div className="flex justify-between items-center pt-3 border-t border-border mt-3">
<Button variant="outline" size="sm" onClick={handleCloseInfoBox}>
Close
</Button>
<Button
variant="default"
size="sm"
onClick={handleFlyToIncident}
disabled={!selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude}
className="flex items-center gap-2"
>
<span>Fly to Incident</span>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</Card>
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
</>
)
}

View File

@ -5,7 +5,7 @@ import { useRef, useState } from "react"
import CrimeTooltips from "./crime-tooltips"
import AdditionalTooltips from "./additional-tooltips"
import SearchTooltip from "./search-control"
import { ReactNode } from "react"
import type { ReactNode } from "react"
// Define the possible control IDs for the crime map
export type ITooltips =
@ -24,19 +24,22 @@ export type ITooltips =
| "alerts"
| "layers"
| "evidence"
| "arrests";
| "arrests"
// Map tools type definition
export interface IMapTools {
id: ITooltips;
label: string;
icon: ReactNode;
description?: string;
id: ITooltips
label: string
icon: ReactNode
description?: string
}
interface TooltipProps {
onControlChange?: (controlId: ITooltips) => void
activeControl?: string
selectedSourceType: string
setSelectedSourceType: (sourceType: string) => void
availableSourceTypes: string[] // This must be string[] to match with API response
selectedYear: number
setSelectedYear: (year: number) => void
selectedMonth: number | "all"
@ -51,15 +54,18 @@ interface TooltipProps {
export default function Tooltips({
onControlChange,
activeControl,
selectedSourceType,
setSelectedSourceType,
availableSourceTypes = [],
selectedYear,
setSelectedYear,
selectedMonth,
setSelectedMonth,
selectedCategory,
setSelectedCategory,
availableYears = [2022, 2023, 2024],
availableYears = [],
categories = [],
crimes = []
crimes = [],
}: TooltipProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [isClient, setIsClient] = useState(false)
@ -68,29 +74,37 @@ export default function Tooltips({
<div ref={containerRef} className="flex flex-col items-center gap-2">
<div className="flex items-center gap-2">
{/* Crime Tooltips Component */}
<CrimeTooltips activeControl={activeControl} onControlChange={onControlChange} />
<CrimeTooltips
activeControl={activeControl}
onControlChange={onControlChange}
sourceType={selectedSourceType}
/>
{/* Additional Tooltips Component */}
<AdditionalTooltips
activeControl={activeControl}
onControlChange={onControlChange}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
selectedMonth={selectedMonth}
setSelectedMonth={setSelectedMonth}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
availableYears={availableYears}
categories={categories}
/>
{/* Additional Tooltips Component */}
<AdditionalTooltips
activeControl={activeControl}
onControlChange={onControlChange}
selectedSourceType={selectedSourceType}
setSelectedSourceType={setSelectedSourceType}
availableSourceTypes={availableSourceTypes}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
selectedMonth={selectedMonth}
setSelectedMonth={setSelectedMonth}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
availableYears={availableYears}
categories={categories}
/>
{/* Search Control Component */}
<SearchTooltip
activeControl={activeControl}
onControlChange={onControlChange}
crimes={crimes} // Pass crimes data here
/>
</div>
</div>
)
{/* Search Control Component */}
<SearchTooltip
activeControl={activeControl}
onControlChange={onControlChange}
crimes={crimes}
sourceType={selectedSourceType}
/>
</div>
</div>
)
}

View File

@ -12,7 +12,7 @@ import { Overlay } from "./overlay"
import MapLegend from "./legends/map-legend"
import UnitsLegend from "./legends/units-legend"
import TimelineLegend from "./legends/timeline-legend"
import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes, useGetCrimeTypes, useGetRecentIncidents } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
import MapSelectors from "./controls/map-selector"
import { cn } from "@/app/_lib/utils"
@ -29,10 +29,11 @@ export default function CrimeMap() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
const [showLegend, setShowLegend] = useState<boolean>(true)
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
const [activeControl, setActiveControl] = useState<ITooltips>("incidents")
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu")
const [selectedYear, setSelectedYear] = useState<number>(2024)
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
const [activeControl, setActiveControl] = useState<ITooltips>("incidents")
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
const [yearProgress, setYearProgress] = useState(0)
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
const [isSearchActive, setIsSearchActive] = useState(false)
@ -48,6 +49,8 @@ export default function CrimeMap() {
const { isFullscreen } = useFullscreen(mapContainerRef)
const { data: availableSourceTypes, isLoading: isTypeLoading } = useGetCrimeTypes()
const {
data: availableYears,
isLoading: isYearsLoading,
@ -68,6 +71,8 @@ export default function CrimeMap() {
const { data: fetchedUnits, isLoading } = useGetUnitsQuery()
const { data: recentIncidents } = useGetRecentIncidents()
useEffect(() => {
if (activeControl === "heatmap" || activeControl === "timeline") {
setUseAllYears(true);
@ -78,20 +83,25 @@ export default function CrimeMap() {
}
}, [activeControl]);
const filteredByYearAndMonth = useMemo(() => {
const crimesBySourceType = useMemo(() => {
if (!crimes) return [];
return crimes.filter(crime => crime.source_type === selectedSourceType);
}, [crimes, selectedSourceType]);
const filteredByYearAndMonth = useMemo(() => {
if (!crimesBySourceType) return [];
if (useAllYears) {
if (useAllMonths) {
return crimes;
return crimesBySourceType;
} else {
return crimes.filter((crime) => {
return crimesBySourceType.filter((crime) => {
return selectedMonth === "all" ? true : crime.month === selectedMonth;
});
}
}
return crimes.filter((crime) => {
return crimesBySourceType.filter((crime) => {
const yearMatch = crime.year === selectedYear;
if (selectedMonth === "all" || useAllMonths) {
@ -100,7 +110,7 @@ export default function CrimeMap() {
return yearMatch && crime.month === selectedMonth;
}
});
}, [crimes, selectedYear, selectedMonth, useAllYears, useAllMonths]);
}, [crimesBySourceType, selectedYear, selectedMonth, useAllYears, useAllMonths]);
const filteredCrimes = useMemo(() => {
if (!filteredByYearAndMonth) return []
@ -119,6 +129,32 @@ export default function CrimeMap() {
})
}, [filteredByYearAndMonth, selectedCategory])
useEffect(() => {
if (selectedSourceType === "cbu") {
if (activeControl !== "clusters" && activeControl !== "reports" &&
activeControl !== "layers" && activeControl !== "search" &&
activeControl !== "alerts") {
setActiveControl("clusters");
setShowClusters(true);
setShowUnclustered(false);
}
}
}, [selectedSourceType, activeControl]);
const handleSourceTypeChange = useCallback((sourceType: string) => {
setSelectedSourceType(sourceType);
if (sourceType === "cbu") {
setActiveControl("clusters");
setShowClusters(true);
setShowUnclustered(false);
} else {
setActiveControl("incidents");
setShowUnclustered(true);
setShowClusters(false);
}
}, []);
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
setSelectedYear(year)
setSelectedMonth(month)
@ -155,6 +191,11 @@ export default function CrimeMap() {
}
const handleControlChange = (controlId: ITooltips) => {
if (selectedSourceType === "cbu" &&
!["clusters", "reports", "layers", "search", "alerts"].includes(controlId as string)) {
return;
}
setActiveControl(controlId);
if (controlId === "clusters") {
@ -187,7 +228,6 @@ export default function CrimeMap() {
setUseAllMonths(false);
}
// Enable EWS in all modes for demo purposes
setShowEWS(true);
}
@ -222,7 +262,7 @@ export default function CrimeMap() {
<Button onClick={() => window.location.reload()}>Retry</Button>
</div>
) : (
<div className="mapbox-container overlay-bg vertical-reveal relative h-[600px]" ref={mapContainerRef}>
<div className="mapbox-container overlay-bg relative h-[600px]" ref={mapContainerRef}>
<div className={cn(
"transition-all duration-300 ease-in-out",
!sidebarCollapsed && isFullscreen && "ml-[400px]"
@ -238,6 +278,8 @@ export default function CrimeMap() {
activeControl={activeControl}
useAllData={useAllYears}
showEWS={showEWS}
recentIncidents={recentIncidents || []}
sourceType={selectedSourceType}
/>
{isFullscreen && (
@ -246,6 +288,9 @@ export default function CrimeMap() {
<Tooltips
activeControl={activeControl}
onControlChange={handleControlChange}
selectedSourceType={selectedSourceType}
setSelectedSourceType={handleSourceTypeChange}
availableSourceTypes={availableSourceTypes || []}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
selectedMonth={selectedMonth}
@ -264,6 +309,7 @@ export default function CrimeMap() {
selectedCategory={selectedCategory}
selectedYear={selectedYear}
selectedMonth={selectedMonth}
sourceType={selectedSourceType} // Pass the sourceType
/>
{isFullscreen && (
<div className="absolute bottom-20 right-0 z-20 p-2">

View File

@ -2,7 +2,7 @@
import { useEffect, useCallback } from "react"
import type mapboxgl from "mapbox-gl"
import mapboxgl from "mapbox-gl"
import type { GeoJSON } from "geojson"
import type { IClusterLayerProps } from "@/app/_utils/types/map"
import { extractCrimeIncidents } from "@/app/_utils/map"
@ -10,6 +10,7 @@ import { extractCrimeIncidents } from "@/app/_utils/map"
interface ExtendedClusterLayerProps extends IClusterLayerProps {
clusteringEnabled?: boolean
showClusters?: boolean
sourceType?: string
}
export default function ClusterLayer({
@ -20,6 +21,7 @@ export default function ClusterLayer({
focusedDistrictId,
clusteringEnabled = false,
showClusters = false,
sourceType = "cbt",
}: ExtendedClusterLayerProps) {
const handleClusterClick = useCallback(
(e: any) => {
@ -35,7 +37,6 @@ export default function ClusterLayer({
const clusterId: number = features[0].properties?.cluster_id as number
try {
// Get the expanded zoom level for this cluster
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(
clusterId,
(err, zoom) => {
@ -46,7 +47,6 @@ export default function ClusterLayer({
const coordinates = (features[0].geometry as any).coordinates
// Explicitly fly to the cluster location
map.flyTo({
center: coordinates,
zoom: zoom ?? 12,
@ -80,13 +80,38 @@ export default function ClusterLayer({
}
if (!map.getSource("crime-incidents")) {
const allIncidents = extractCrimeIncidents(crimes, filterCategory)
let features: GeoJSON.Feature[] = []
if (sourceType === "cbu") {
features = crimes.map(crime => ({
type: "Feature",
properties: {
district_id: crime.district_id,
district_name: crime.districts ? crime.districts.name : "Unknown",
crime_count: crime.number_of_crime || 0,
level: crime.level,
category: filterCategory !== "all" ? filterCategory : "All",
year: crime.year,
month: crime.month,
isCBU: true,
},
geometry: {
type: "Point",
coordinates: [
crime.districts?.geographics?.[0]?.longitude || 0,
crime.districts?.geographics?.[0]?.latitude || 0,
],
},
})) as GeoJSON.Feature[]
} else {
features = extractCrimeIncidents(crimes, filterCategory).filter(Boolean) as GeoJSON.Feature[]
}
map.addSource("crime-incidents", {
type: "geojson",
data: {
type: "FeatureCollection",
features: allIncidents as GeoJSON.Feature[],
features: features,
},
cluster: clusteringEnabled,
clusterMaxZoom: 14,
@ -131,6 +156,103 @@ export default function ClusterLayer({
})
}
if (sourceType === "cbu" && !map.getLayer("crime-points")) {
map.addLayer({
id: "crime-points",
type: "circle",
source: "crime-incidents",
filter: ["!", ["has", "point_count"]],
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
8,
["interpolate", ["linear"], ["get", "crime_count"], 0, 5, 100, 20],
12,
["interpolate", ["linear"], ["get", "crime_count"], 0, 8, 100, 30],
],
"circle-color": [
"match",
["get", "level"],
"low",
"#47B39C",
"medium",
"#FFC154",
"high",
"#EC6B56",
"#888888",
],
"circle-opacity": 0.7,
"circle-stroke-width": 1,
"circle-stroke-color": "#ffffff",
},
layout: {
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
},
})
map.addLayer({
id: "crime-count-labels",
type: "symbol",
source: "crime-incidents",
filter: ["!", ["has", "point_count"]],
layout: {
"text-field": "{crime_count}",
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
"text-size": 12,
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
},
paint: {
"text-color": "#ffffff",
},
})
map.on("mouseenter", "crime-points", () => {
map.getCanvas().style.cursor = "pointer"
})
map.on("mouseleave", "crime-points", () => {
map.getCanvas().style.cursor = ""
})
const handleCrimePointClick = (e: any) => {
if (!map) return
e.originalEvent.stopPropagation()
e.preventDefault()
const features = map.queryRenderedFeatures(e.point, { layers: ["crime-points"] })
if (features.length > 0) {
const feature = features[0]
const props = feature.properties
const coordinates = (feature.geometry as any).coordinates.slice()
if (props) {
if (props) {
const popupHTML = `
<div class="p-3">
<h3 class="font-bold">${props.district_name}</h3>
<div class="mt-2">
<p>Total Crimes: <b>${props.crime_count}</b></p>
<p>Crime Level: <b>${props.level}</b></p>
<p>Year: ${props.year} - Month: ${props.month}</p>
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
</div>
</div>
`
new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map)
}
}
}
}
map.off("click", "crime-points", handleCrimePointClick)
map.on("click", "crime-points", handleCrimePointClick)
}
map.on("mouseenter", "clusters", () => {
map.getCanvas().style.cursor = "pointer"
})
@ -139,36 +261,66 @@ export default function ClusterLayer({
map.getCanvas().style.cursor = ""
})
// Remove and re-add click handler to avoid duplicates
map.off("click", "clusters", handleClusterClick)
map.on("click", "clusters", handleClusterClick)
} else {
// Update source clustering option
try {
// We need to recreate the source if we're changing the clustering option
const currentSource = map.getSource("crime-incidents") as mapboxgl.GeoJSONSource
const data = (currentSource as any)._data // Get current data
const data = (currentSource as any)._data
// If clustering state has changed, recreate the source
const existingClusterState = (currentSource as any).options?.cluster
if (existingClusterState !== clusteringEnabled) {
// Remove existing layers that use this source
const sourceTypeChanged =
data.features.length > 0 &&
((sourceType === "cbu" && !data.features[0].properties.isCBU) ||
(sourceType !== "cbu" && data.features[0].properties.isCBU))
if (existingClusterState !== clusteringEnabled || sourceTypeChanged) {
if (map.getLayer("clusters")) map.removeLayer("clusters")
if (map.getLayer("cluster-count")) map.removeLayer("cluster-count")
if (map.getLayer("unclustered-point")) map.removeLayer("unclustered-point")
if (map.getLayer("crime-points")) map.removeLayer("crime-points")
if (map.getLayer("crime-count-labels")) map.removeLayer("crime-count-labels")
// Remove and recreate source with new clustering setting
map.removeSource("crime-incidents")
let features: GeoJSON.Feature[] = []
if (sourceType === "cbu") {
features = crimes.map(crime => ({
type: "Feature",
properties: {
district_id: crime.district_id,
district_name: crime.districts ? crime.districts.name : "Unknown",
crime_count: crime.number_of_crime || 0,
level: crime.level,
category: filterCategory !== "all" ? filterCategory : "All",
year: crime.year,
month: crime.month,
isCBU: true,
},
geometry: {
type: "Point",
coordinates: [
crime.districts?.geographics?.[0]?.longitude || 0,
crime.districts?.geographics?.[0]?.latitude || 0,
],
},
})) as GeoJSON.Feature[]
} else {
features = extractCrimeIncidents(crimes, filterCategory).filter(Boolean) as GeoJSON.Feature[]
}
map.addSource("crime-incidents", {
type: "geojson",
data: data,
data: {
type: "FeatureCollection",
features: features,
},
cluster: clusteringEnabled,
clusterMaxZoom: 14,
clusterRadius: 50,
})
// Re-add the layers
if (!map.getLayer("clusters")) {
map.addLayer(
{
@ -206,12 +358,108 @@ export default function ClusterLayer({
},
})
}
if (sourceType === "cbu") {
if (!map.getLayer("crime-points")) {
map.addLayer({
id: "crime-points",
type: "circle",
source: "crime-incidents",
filter: ["!", ["has", "point_count"]],
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
8,
["interpolate", ["linear"], ["get", "crime_count"], 0, 5, 100, 20],
12,
["interpolate", ["linear"], ["get", "crime_count"], 0, 8, 100, 30],
],
"circle-color": [
"match",
["get", "level"],
"low",
"#47B39C",
"medium",
"#FFC154",
"high",
"#EC6B56",
"#888888",
],
"circle-opacity": 0.7,
"circle-stroke-width": 1,
"circle-stroke-color": "#ffffff",
},
layout: {
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
},
})
map.addLayer({
id: "crime-count-labels",
type: "symbol",
source: "crime-incidents",
filter: ["!", ["has", "point_count"]],
layout: {
"text-field": "{crime_count}",
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
"text-size": 12,
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
},
paint: {
"text-color": "#ffffff",
},
})
map.on("mouseenter", "crime-points", () => {
map.getCanvas().style.cursor = "pointer"
})
map.on("mouseleave", "crime-points", () => {
map.getCanvas().style.cursor = ""
})
const handleCrimePointClick = (e: any) => {
if (!map) return
e.originalEvent.stopPropagation()
e.preventDefault()
const features = map.queryRenderedFeatures(e.point, { layers: ["crime-points"] })
if (features.length > 0) {
const feature = features[0]
const props = feature.properties
const coordinates = (feature.geometry as any).coordinates.slice()
if (props) {
const popupHTML = `
<div class="p-3">
<h3 class="font-bold">${props.district_name}</h3>
<div class="mt-2">
<p>Total Crimes: <b>${props.crime_count}</b></p>
<p>Crime Level: <b>${props.level}</b></p>
<p>Year: ${props.year} - Month: ${props.month}</p>
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
</div>
</div>
`
new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map)
}
}
}
map.off("click", "crime-points", handleCrimePointClick)
map.on("click", "crime-points", handleCrimePointClick)
}
}
}
} catch (error) {
console.error("Error updating cluster source:", error)
}
// Update visibility based on focused district and showClusters flag
if (map.getLayer("clusters")) {
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
}
@ -224,7 +472,19 @@ export default function ClusterLayer({
)
}
// Update the cluster click handler
if (sourceType === "cbu" && map.getLayer("crime-points")) {
map.setLayoutProperty(
"crime-points",
"visibility",
showClusters && !focusedDistrictId ? "visible" : "none",
)
map.setLayoutProperty(
"crime-count-labels",
"visibility",
showClusters && !focusedDistrictId ? "visible" : "none",
)
}
map.off("click", "clusters", handleClusterClick)
map.on("click", "clusters", handleClusterClick)
}
@ -242,36 +502,150 @@ export default function ClusterLayer({
return () => {
if (map) {
map.off("click", "clusters", handleClusterClick)
if (sourceType === "cbu" && map.getLayer("crime-points")) {
// Define properly typed event handlers
const crimePointsMouseEnter = function () {
if (map && map.getCanvas()) {
map.getCanvas().style.cursor = "pointer";
}
};
const crimePointsMouseLeave = function () {
if (map && map.getCanvas()) {
map.getCanvas().style.cursor = "";
}
};
const crimePointsClick = function (e: mapboxgl.MapMouseEvent) {
if (!map) return;
e.originalEvent.stopPropagation();
e.preventDefault();
const features = map.queryRenderedFeatures(e.point, { layers: ["crime-points"] });
if (features.length > 0) {
const feature = features[0];
const props = feature.properties;
const coordinates = (feature.geometry as any).coordinates.slice();
if (props) {
const popupHTML = `
<div class="p-3">
<h3 class="font-bold">${props.district_name}</h3>
<div class="mt-2">
<p>Total Crimes: <b>${props.crime_count}</b></p>
<p>Crime Level: <b>${props.level}</b></p>
<p>Year: ${props.year} - Month: ${props.month}</p>
${filterCategory !== "all" ? `<p>Category: ${filterCategory}</p>` : ""}
</div>
</div>
`;
new mapboxgl.Popup()
.setLngLat(coordinates)
.setHTML(popupHTML)
.addTo(map);
}
}
};
// Remove event listeners with properly typed handlers
map.off("mouseenter", "crime-points", crimePointsMouseEnter);
map.off("mouseleave", "crime-points", crimePointsMouseLeave);
map.off("click", "crime-points", crimePointsClick);
}
}
}
}, [map, visible, crimes, filterCategory, focusedDistrictId, handleClusterClick, clusteringEnabled, showClusters])
}, [
map,
visible,
crimes,
filterCategory,
focusedDistrictId,
handleClusterClick,
clusteringEnabled,
showClusters,
sourceType,
])
// Update crime incidents data when filters change
useEffect(() => {
if (!map || !map.getSource("crime-incidents")) return
try {
const allIncidents = extractCrimeIncidents(crimes, filterCategory)
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
type: "FeatureCollection",
features: allIncidents as GeoJSON.Feature[],
})
let features: GeoJSON.Feature[]
if (sourceType === "cbu") {
features = crimes.map(crime => ({
type: "Feature",
properties: {
district_id: crime.district_id,
district_name: crime.districts ? crime.districts.name : "Unknown",
crime_count: crime.number_of_crime || 0,
level: crime.level,
category: filterCategory !== "all" ? filterCategory : "All",
year: crime.year,
month: crime.month,
isCBU: true,
},
geometry: {
type: "Point",
coordinates: [
crime.districts?.geographics?.[0]?.longitude || 0,
crime.districts?.geographics?.[0]?.latitude || 0,
],
},
})) as GeoJSON.Feature[]
} else {
features = extractCrimeIncidents(crimes, filterCategory).filter(Boolean) as GeoJSON.Feature[]
}
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
type: "FeatureCollection",
features: features,
})
} catch (error) {
console.error("Error updating incident data:", error)
}
}, [map, crimes, filterCategory])
}, [map, crimes, filterCategory, sourceType])
// Update visibility when showClusters changes
useEffect(() => {
if (!map || !map.getLayer("clusters") || !map.getLayer("cluster-count")) return
if (!map) return
try {
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
map.setLayoutProperty("cluster-count", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
if (map.getLayer("clusters")) {
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
}
if (map.getLayer("cluster-count")) {
map.setLayoutProperty(
"cluster-count",
"visibility",
showClusters && !focusedDistrictId ? "visible" : "none",
)
}
if (sourceType === "cbu") {
if (map.getLayer("crime-points")) {
map.setLayoutProperty(
"crime-points",
"visibility",
showClusters && !focusedDistrictId ? "visible" : "none",
)
}
if (map.getLayer("crime-count-labels")) {
map.setLayoutProperty(
"crime-count-labels",
"visibility",
showClusters && !focusedDistrictId ? "visible" : "none",
)
}
}
} catch (error) {
console.error("Error updating cluster visibility:", error)
}
}, [map, showClusters, focusedDistrictId])
}, [map, showClusters, focusedDistrictId, sourceType])
return null
}

View File

@ -76,6 +76,7 @@ interface LayersProps {
tilesetId?: string
useAllData?: boolean
showEWS?: boolean
sourceType?: string
}
export default function Layers({
@ -90,6 +91,7 @@ export default function Layers({
tilesetId = MAPBOX_TILESET_ID,
useAllData = false,
showEWS = true,
sourceType = "cbt",
}: LayersProps) {
const { current: map } = useMap()
@ -416,11 +418,11 @@ export default function Layers({
if (!visible) return null
const crimesVisible = activeControl === "incidents"
const showHeatmapLayer = activeControl === "heatmap"
const showHeatmapLayer = activeControl === "heatmap" && sourceType !== "cbu"
const showUnitsLayer = activeControl === "units"
const showTimelineLayer = activeControl === "timeline"
const showDistrictFill = activeControl === "incidents" || activeControl === "clusters"
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline"
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline" && sourceType !== "cbu"
return (
<>
@ -484,6 +486,7 @@ export default function Layers({
focusedDistrictId={focusedDistrictId}
clusteringEnabled={activeControl === "clusters"}
showClusters={activeControl === "clusters"}
sourceType={sourceType}
/>
<UnclusteredPointLayer

View File

@ -1171,3 +1171,4 @@ export function generateId(
return result.trim();
}

View File

@ -0,0 +1,56 @@
type Severity = "Low" | "Medium" | "High" | "Unknown";
export function getSeverity(crimeName: string): Severity {
const high = [
"Pembunuhan",
"Perkosaan",
"Penculikan",
"Pembakaran",
"Kebakaran / Meletus",
"Curas",
"Curanmor",
"Lahgun Senpi/Handak/Sajam",
"Trafficking In Person",
];
const medium = [
"Penganiayaan Berat",
"Penganiayaan Ringan",
"Perjudian",
"Sumpah Palsu",
"Pemalsuan Materai",
"Pemalsuan Surat",
"Perbuatan Tidak Menyenangkan",
"Premanisme",
"Pemerasan Dan Pengancaman",
"Penggelapan",
"Penipuan",
"Pengeroyokan",
"PKDRT",
"Money Loudering",
"Illegal Logging",
"Illegal Mining",
];
const low = [
"Member Suap",
"Menerima Suap",
"Penghinaan",
"Perzinahan",
"Terhadap Ketertiban Umum",
"Membahayakan Kam Umum",
"Kenakalan Remaja",
"Perlindungan Anak",
"Perlindungan TKI",
"Perlindungan Saksi Korban",
"Pekerjakan Anak",
"Pengrusakan",
"ITE",
"Perlindungan Konsumen",
];
if (high.includes(crimeName)) return "High";
if (medium.includes(crimeName)) return "Medium";
if (low.includes(crimeName)) return "Low";
return "Unknown";
}

View File

@ -104,7 +104,7 @@ export interface IIncidentLogs {
source: string;
description: string;
verified: boolean;
severity: 'high' | 'medium' | 'low';
severity: "Low" | "Medium" | "High" | "Unknown";
timestamp: Date;
created_at: Date;
updated_at: Date;

View File

@ -1,33 +1,157 @@
export const districtCenters = [
{ kecamatan: "Sumbersari", lat: -8.170662, lng: 113.727582 },
{ kecamatan: "Wuluhan", lat: -8.365478, lng: 113.537137 },
{ kecamatan: "Bangsalsari", lat: -8.2013, lng: 113.5323 },
{ kecamatan: "Kaliwates", lat: -8.1725, lng: 113.7000 },
{ kecamatan: "Puger", lat: -8.477942, lng: 113.370447 },
{ kecamatan: "Ambulu", lat: -8.381355, lng: 113.608561 },
{ kecamatan: "Silo", lat: -8.22917, lng: 113.86500 },
{ kecamatan: "Sumberbaru", lat: -8.119229, lng: 113.392973 },
{ kecamatan: "Patrang", lat: -8.14611, lng: 113.71250 },
{ kecamatan: "Tanggul", lat: -8.161572, lng: 113.451239 },
{ kecamatan: "Jenggawah", lat: -8.296967, lng: 113.656173 },
{ kecamatan: "Gumukmas", lat: -8.32306, lng: 113.40667 },
{ kecamatan: "Rambipuji", lat: -8.210581, lng: 113.603610 },
{ kecamatan: "Ajung", lat: -8.245360, lng: 113.653218 },
{ kecamatan: "Balung", lat: -8.26861, lng: 113.52667 },
{ kecamatan: "Tempurejo", lat: -8.4000, lng: 113.8000 },
{ kecamatan: "Kalisat", lat: -8.1500, lng: 113.8000 },
{ kecamatan: "Umbulsari", lat: -8.2500, lng: 113.5000 },
{ kecamatan: "Kencong", lat: -8.3500, lng: 113.4000 },
{ kecamatan: "Ledokombo", lat: -8.2000, lng: 113.8000 },
{ kecamatan: "Mumbulsari", lat: -8.3000, lng: 113.6000 },
{ kecamatan: "Panti", lat: -8.1500, lng: 113.6000 },
{ kecamatan: "Sumberjambe", lat: -8.1000, lng: 113.8000 },
{ kecamatan: "Sukowono", lat: -8.2000, lng: 113.7000 },
{ kecamatan: "Mayang", lat: -8.2500, lng: 113.7000 },
{ kecamatan: "Semboro", lat: -8.3000, lng: 113.5000 },
{ kecamatan: "Jombang", lat: -8.3500, lng: 113.5000 },
{ kecamatan: "Pakusari", lat: -8.2000, lng: 113.7000 },
{ kecamatan: "Arjasa", lat: -8.1500, lng: 113.7000 },
{ kecamatan: "Sukorambi", lat: -8.2000, lng: 113.6000 },
{ kecamatan: "Jelbuk", lat: -8.1000, lng: 113.7000 },
{
kecamatan: "Ajung",
lat: -8.243799,
lng: 113.642826,
},
{
kecamatan: "Ambulu",
lat: -8.3456,
lng: 113.6106,
},
{
kecamatan: "Arjasa",
lat: -8.1042,
lng: 113.7398,
},
{
kecamatan: "Balung",
lat: -8.2592,
lng: 113.5464,
},
{
kecamatan: "Bangsalsari",
lat: -8.1721,
lng: 113.5312,
},
{
kecamatan: "Gumukmas",
lat: -8.3117,
lng: 113.4297,
},
{
kecamatan: "Jelbuk",
lat: -8.0831,
lng: 113.7649,
},
{
kecamatan: "Jenggawah",
lat: -8.2409,
lng: 113.6407,
},
{
kecamatan: "Jombang",
lat: -8.227065,
lng: 113.345340,
},
{
kecamatan: "Kalisat",
lat: -8.1297,
lng: 113.8218,
},
{
kecamatan: "Kaliwates",
lat: -8.1725,
lng: 113.6875,
},
{
kecamatan: "Kencong",
lat: -8.2827,
lng: 113.3769,
},
{
kecamatan: "Ledokombo",
lat: -8.1582,
lng: 113.9055,
},
{
kecamatan: "Mayang",
lat: -8.1903,
lng: 113.8264,
},
{
kecamatan: "Mumbulsari",
lat: -8.252744,
lng: 113.741842,
},
{
kecamatan: "Panti",
lat: -8.1054,
lng: 113.6218,
},
{
kecamatan: "Pakusari",
lat: -8.1308,
lng: 113.7577,
},
{
kecamatan: "Patrang",
lat: -8.1402,
lng: 113.7128,
},
{
kecamatan: "Puger",
lat: -8.314691,
lng: 113.474320,
},
{
kecamatan: "Rambipuji",
lat: -8.2069,
lng: 113.6075,
},
{
kecamatan: "Semboro",
lat: -8.1876,
lng: 113.4583,
},
{
kecamatan: "Silo",
lat: -8.2276,
lng: 113.8757,
},
{
kecamatan: "Sukorambi",
lat: -8.1094,
lng: 113.6589,
},
{
kecamatan: "Sukowono",
lat: -8.0547,
lng: 113.8853,
},
{
kecamatan: "Sumberbaru",
lat: -8.1362,
lng: 113.3879,
},
{
kecamatan: "Sumberjambe",
lat: -8.0634,
lng: 113.9351,
},
{
kecamatan: "Sumbersari",
lat: -8.1834,
lng: 113.7226,
},
{
kecamatan: "Tanggul",
lat: -8.096066,
lng: 113.492787,
},
{
kecamatan: "Tempurejo",
lat: -8.388294936847124,
lng: 113.73276107141628,
},
{
kecamatan: "Umbulsari",
lat: -8.2623,
lng: 113.4633,
},
{
kecamatan: "Wuluhan",
lat: -8.3283,
lng: 113.5357,
},
];

View File

@ -30,13 +30,13 @@ class DatabaseSeeder {
// Daftar semua seeders di sini
this.seeders = [
new RoleSeeder(prisma),
new ResourceSeeder(prisma),
new PermissionSeeder(prisma),
new CrimeCategoriesSeeder(prisma),
new GeoJSONSeeder(prisma),
new UnitSeeder(prisma),
new DemographicsSeeder(prisma),
// new RoleSeeder(prisma),
// new ResourceSeeder(prisma),
// new PermissionSeeder(prisma),
// new CrimeCategoriesSeeder(prisma),
// new GeoJSONSeeder(prisma),
// new UnitSeeder(prisma),
// new DemographicsSeeder(prisma),
new CrimesSeeder(prisma),
// new CrimeIncidentsByUnitSeeder(prisma),
new CrimeIncidentsByTypeSeeder(prisma),

View File

@ -696,9 +696,6 @@ export class CrimeIncidentsByTypeSeeder {
console.log(`\n📊 ${year} Data Summary:`);
console.log(`├─ Total incidents created: ${stats.incidents}`);
console.log(`├─ Districts processed: ${stats.districts.size}`);
console.log(
`├─ Categories: ${stats.matched} matched, ${stats.mismatched} mismatched`,
);
console.log(`└─ Total resolved cases: ${stats.resolved}`);
}
@ -731,22 +728,18 @@ export class CrimeIncidentsByTypeSeeder {
const centerLat = districtCenter.lat;
const centerLng = districtCenter.lng;
let scalingFactor = 0.3;
const effectiveLandArea = Math.max(landArea || 1, 1);
const radiusKm = Math.sqrt(effectiveLandArea) * scalingFactor;
// Skala kecil: radius hanya 0.5 km dari center (sekitar 500 meter)
const radiusKm = 0.5;
const radiusDeg = radiusKm / 111;
for (let i = 0; i < numPoints; i++) {
const angle = Math.random() * 2 * Math.PI;
const distanceFactor = Math.pow(Math.random(), 1.5);
const distance = distanceFactor * radiusDeg;
// Jarak random, lebih padat di tengah
const distance = Math.pow(Math.random(), 1.5) * radiusDeg;
const latitude = centerLat + distance * Math.cos(angle);
const longitude = centerLng +
distance * Math.sin(angle) /
Math.cos(centerLat * Math.PI / 180);
distance * Math.sin(angle) / Math.cos(centerLat * Math.PI / 180);
const pointRadius = distance * 111000;

View File

@ -1,18 +1,18 @@
import {
PrismaClient,
crime_incidents,
crime_rates,
crimes,
events,
PrismaClient,
session_status,
users,
} from '@prisma/client';
import fs from 'fs';
import path from 'path';
import { parse } from 'csv-parse/sync';
import { generateId, generateIdWithDbCounter } from '../../app/_utils/common';
import { CRegex } from '../../app/_utils/const/regex';
} from "@prisma/client";
import fs from "fs";
import path from "path";
import { parse } from "csv-parse/sync";
import { generateId, generateIdWithDbCounter } from "../../app/_utils/common";
import { CRegex } from "../../app/_utils/const/regex";
import db from "../db";
interface ICreateUser {
id: string;
@ -34,14 +34,17 @@ export class CrimesSeeder {
constructor(private prisma: PrismaClient) {}
async run(): Promise<void> {
console.log('🌱 Seeding crimes data...');
console.log("🌱 Seeding crimes data...");
try {
// Create test user
const user = await this.createUsers();
await db.crime_incidents.deleteMany();
await db.crimes.deleteMany();
if (!user) {
throw new Error('Failed to create user');
throw new Error("Failed to create user");
}
// Create 5 events
@ -60,39 +63,39 @@ export class CrimesSeeder {
await this.importYearlyCrimeDataByType();
await this.importSummaryByType();
console.log('✅ Crime seeding completed successfully.');
console.log("✅ Crime seeding completed successfully.");
} catch (error) {
console.error('❌ Error seeding crimes:', error);
console.error("❌ Error seeding crimes:", error);
throw error;
}
}
private async createUsers() {
const existingUser = await this.prisma.users.findFirst({
where: { email: 'sigapcompany@gmail.com' },
where: { email: "sigapcompany@gmail.com" },
});
if (existingUser) {
console.log('Users already exist, skipping creation.');
console.log("Users already exist, skipping creation.");
return existingUser;
}
let roleId = await this.prisma.roles.findFirst({
where: { name: 'admin' },
where: { name: "admin" },
});
if (!roleId) {
roleId = await this.prisma.roles.create({
data: {
name: 'admin',
description: 'Administrator role',
name: "admin",
description: "Administrator role",
},
});
}
const newUser = await this.prisma.users.create({
data: {
email: 'sigapcompany@gmail.com',
email: "sigapcompany@gmail.com",
roles_id: roleId.id,
confirmed_at: new Date(),
email_confirmed_at: new Date(),
@ -106,9 +109,9 @@ export class CrimesSeeder {
is_anonymous: false,
profile: {
create: {
first_name: 'Admin',
last_name: 'Sigap',
username: 'adminsigap',
first_name: "Admin",
last_name: "Sigap",
username: "adminsigap",
},
},
},
@ -125,7 +128,7 @@ export class CrimesSeeder {
});
if (existingEvent) {
console.log('Events already exist, skipping creation.');
console.log("Events already exist, skipping creation.");
return existingEvent;
}
@ -144,7 +147,7 @@ export class CrimesSeeder {
const existingSession = await this.prisma.sessions.findFirst();
if (existingSession) {
console.log('Sessions already exist, skipping creation.');
console.log("Sessions already exist, skipping creation.");
return;
}
@ -169,24 +172,24 @@ export class CrimesSeeder {
}
private async importMonthlyCrimeData() {
console.log('Importing monthly crime data...');
console.log("Importing monthly crime data...");
const existingCrimes = await this.prisma.crimes.findFirst({
where: {
source_type: 'cbu',
source_type: "cbu",
},
});
if (existingCrimes) {
console.log('General crimes data already exists, skipping import.');
console.log("General crimes data already exists, skipping import.");
return;
}
const csvFilePath = path.resolve(
__dirname,
'../data/excels/crimes/crime_monthly_by_unit.csv'
"../data/excels/crimes/crime_monthly_by_unit.csv",
);
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
const records = parse(fileContent, {
columns: true,
@ -201,30 +204,30 @@ export class CrimesSeeder {
const city = await this.prisma.cities.findFirst({
where: {
name: 'Jember',
name: "Jember",
},
});
if (!city) {
console.error('City not found: Jember');
console.error("City not found: Jember");
return;
}
const year = parseInt(record.year);
const crimeId = await generateIdWithDbCounter(
'crimes',
"crimes",
{
prefix: 'CR',
prefix: "CR",
segments: {
codes: [city.id],
sequentialDigits: 4,
year,
},
format: '{prefix}-{codes}-{sequence}-{year}',
separator: '-',
uniquenessStrategy: 'counter',
format: "{prefix}-{codes}-{sequence}-{year}",
separator: "-",
uniquenessStrategy: "counter",
},
CRegex.CR_YEAR_SEQUENCE
CRegex.CR_YEAR_SEQUENCE,
);
crimesData.push({
@ -237,7 +240,7 @@ export class CrimesSeeder {
number_of_crime: parseInt(record.number_of_crime),
crime_cleared: parseInt(record.crime_cleared) || 0,
score: parseFloat(record.score),
source_type: 'cbu',
source_type: "cbu",
});
processedDistricts.add(record.district_id);
@ -249,25 +252,25 @@ export class CrimesSeeder {
}
private async importYearlyCrimeData() {
console.log('Importing yearly crime data...');
console.log("Importing yearly crime data...");
const existingYearlySummary = await this.prisma.crimes.findFirst({
where: {
month: null,
source_type: 'cbu',
source_type: "cbu",
},
});
if (existingYearlySummary) {
console.log('Yearly crime data already exists, skipping import.');
console.log("Yearly crime data already exists, skipping import.");
return;
}
const csvFilePath = path.resolve(
__dirname,
'../data/excels/crimes/crime_yearly_by_unit.csv'
"../data/excels/crimes/crime_yearly_by_unit.csv",
);
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
const records = parse(fileContent, {
columns: true,
@ -296,33 +299,33 @@ export class CrimesSeeder {
}
const crimeId = await generateIdWithDbCounter(
'crimes',
"crimes",
{
prefix: 'CR',
prefix: "CR",
segments: {
codes: [city.id],
sequentialDigits: 4,
year,
},
format: '{prefix}-{codes}-{sequence}-{year}',
separator: '-',
uniquenessStrategy: 'counter',
format: "{prefix}-{codes}-{sequence}-{year}",
separator: "-",
uniquenessStrategy: "counter",
},
CRegex.CR_YEAR_SEQUENCE
CRegex.CR_YEAR_SEQUENCE,
);
crimesData.push({
id: crimeId,
district_id: record.district_id,
level: crimeRate,
method: record.method || 'kmeans',
method: record.method || "kmeans",
month: null,
year: year,
number_of_crime: parseInt(record.number_of_crime),
crime_cleared: parseInt(record.crime_cleared) || 0,
avg_crime: parseFloat(record.avg_crime) || 0,
score: parseInt(record.score),
source_type: 'cbu',
source_type: "cbu",
});
}
@ -332,26 +335,26 @@ export class CrimesSeeder {
}
private async importAllYearSummaries() {
console.log('Importing all-year (2020-2024) crime summaries...');
console.log("Importing all-year (2020-2024) crime summaries...");
const existingAllYearSummaries = await this.prisma.crimes.findFirst({
where: {
month: null,
year: null,
source_type: 'cbu',
source_type: "cbu",
},
});
if (existingAllYearSummaries) {
console.log('All-year crime summaries already exist, skipping import.');
console.log("All-year crime summaries already exist, skipping import.");
return;
}
const csvFilePath = path.resolve(
__dirname,
'../data/excels/crimes/crime_summary_by_unit.csv'
"../data/excels/crimes/crime_summary_by_unit.csv",
);
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
const records = parse(fileContent, {
columns: true,
@ -380,32 +383,32 @@ export class CrimesSeeder {
}
const crimeId = await generateIdWithDbCounter(
'crimes',
"crimes",
{
prefix: 'CR',
prefix: "CR",
segments: {
codes: [city.id],
sequentialDigits: 4,
},
format: '{prefix}-{codes}-{sequence}',
separator: '-',
uniquenessStrategy: 'counter',
format: "{prefix}-{codes}-{sequence}",
separator: "-",
uniquenessStrategy: "counter",
},
CRegex.CR_SEQUENCE_END
CRegex.CR_SEQUENCE_END,
);
crimesData.push({
id: crimeId,
district_id: districtId,
level: crimeRate,
method: 'kmeans',
method: "kmeans",
month: null,
year: null,
number_of_crime: parseInt(record.crime_total),
crime_cleared: parseInt(record.crime_cleared) || 0,
avg_crime: parseFloat(record.avg_crime) || 0,
score: parseFloat(record.score),
source_type: 'cbu',
source_type: "cbu",
});
}
@ -415,24 +418,24 @@ export class CrimesSeeder {
}
private async importMonthlyCrimeDataByType() {
console.log('Importing monthly crime data by type...');
console.log("Importing monthly crime data by type...");
const existingCrimeByType = await this.prisma.crimes.findFirst({
where: {
source_type: 'cbt',
source_type: "cbt",
},
});
if (existingCrimeByType) {
console.log('Crime data by type already exists, skipping import.');
console.log("Crime data by type already exists, skipping import.");
return;
}
const csvFilePath = path.resolve(
__dirname,
'../data/excels/crimes/crime_monthly_by_type.csv'
"../data/excels/crimes/crime_monthly_by_type.csv",
);
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
const records = parse(fileContent, {
columns: true,
@ -446,43 +449,43 @@ export class CrimesSeeder {
const city = await this.prisma.cities.findFirst({
where: {
name: 'Jember',
name: "Jember",
},
});
if (!city) {
console.error('City not found: Jember');
console.error("City not found: Jember");
continue;
}
const year = parseInt(record.year);
const crimeId = await generateIdWithDbCounter(
'crimes',
"crimes",
{
prefix: 'CR',
prefix: "CR",
segments: {
codes: [city.id],
sequentialDigits: 4,
year,
},
format: '{prefix}-{codes}-{sequence}-{year}',
separator: '-',
uniquenessStrategy: 'counter',
format: "{prefix}-{codes}-{sequence}-{year}",
separator: "-",
uniquenessStrategy: "counter",
},
CRegex.CR_YEAR_SEQUENCE
CRegex.CR_YEAR_SEQUENCE,
);
crimesData.push({
id: crimeId,
district_id: record.district_id,
level: crimeRate,
method: record.method || 'kmeans',
method: record.method || "kmeans",
month: parseInt(record.month_num),
year: parseInt(record.year),
number_of_crime: parseInt(record.number_of_crime),
crime_cleared: parseInt(record.crime_cleared) || 0,
score: parseFloat(record.score),
source_type: 'cbt',
source_type: "cbt",
});
}
@ -492,25 +495,25 @@ export class CrimesSeeder {
}
private async importYearlyCrimeDataByType() {
console.log('Importing yearly crime data by type...');
console.log("Importing yearly crime data by type...");
const existingYearlySummary = await this.prisma.crimes.findFirst({
where: {
month: null,
source_type: 'cbt',
source_type: "cbt",
},
});
if (existingYearlySummary) {
console.log('Yearly crime data by type already exists, skipping import.');
console.log("Yearly crime data by type already exists, skipping import.");
return;
}
const csvFilePath = path.resolve(
__dirname,
'../data/excels/crimes/crime_yearly_by_type.csv'
"../data/excels/crimes/crime_yearly_by_type.csv",
);
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
const records = parse(fileContent, {
columns: true,
@ -539,33 +542,33 @@ export class CrimesSeeder {
}
const crimeId = await generateIdWithDbCounter(
'crimes',
"crimes",
{
prefix: 'CR',
prefix: "CR",
segments: {
codes: [city.id],
sequentialDigits: 4,
year,
},
format: '{prefix}-{codes}-{sequence}-{year}',
separator: '-',
uniquenessStrategy: 'counter',
format: "{prefix}-{codes}-{sequence}-{year}",
separator: "-",
uniquenessStrategy: "counter",
},
CRegex.CR_YEAR_SEQUENCE
CRegex.CR_YEAR_SEQUENCE,
);
crimesData.push({
id: crimeId,
district_id: record.district_id,
level: crimeRate,
method: record.method || 'kmeans',
method: record.method || "kmeans",
month: null,
year: year,
number_of_crime: parseInt(record.number_of_crime),
crime_cleared: parseInt(record.crime_cleared) || 0,
avg_crime: parseFloat(record.avg_crime) || 0,
score: parseInt(record.score),
source_type: 'cbt',
source_type: "cbt",
});
}
@ -575,24 +578,24 @@ export class CrimesSeeder {
}
private async importSummaryByType() {
console.log('Importing crime summary by type...');
console.log("Importing crime summary by type...");
const existingSummary = await this.prisma.crimes.findFirst({
where: {
source_type: 'cbt',
source_type: "cbt",
},
});
if (existingSummary) {
console.log('Crime summary by type already exists, skipping import.');
console.log("Crime summary by type already exists, skipping import.");
return;
}
const csvFilePath = path.resolve(
__dirname,
'../data/excels/crimes/crime_summary_by_type.csv'
"../data/excels/crimes/crime_summary_by_type.csv",
);
const fileContent = fs.readFileSync(csvFilePath, { encoding: 'utf-8' });
const fileContent = fs.readFileSync(csvFilePath, { encoding: "utf-8" });
const records = parse(fileContent, {
columns: true,
@ -606,42 +609,42 @@ export class CrimesSeeder {
const city = await this.prisma.cities.findFirst({
where: {
name: 'Jember',
name: "Jember",
},
});
if (!city) {
console.error('City not found: Jember');
console.error("City not found: Jember");
continue;
}
const crimeId = await generateIdWithDbCounter(
'crimes',
"crimes",
{
prefix: 'CR',
prefix: "CR",
segments: {
codes: [city.id],
sequentialDigits: 4,
},
format: '{prefix}-{codes}-{sequence}',
separator: '-',
uniquenessStrategy: 'counter',
format: "{prefix}-{codes}-{sequence}",
separator: "-",
uniquenessStrategy: "counter",
},
CRegex.CR_SEQUENCE_END
CRegex.CR_SEQUENCE_END,
);
crimesData.push({
id: crimeId,
district_id: record.district_id,
level: crimeRate,
method: 'kmeans',
method: "kmeans",
month: null,
year: null,
number_of_crime: parseInt(record.crime_total),
crime_cleared: parseInt(record.crime_cleared) || 0,
avg_crime: parseFloat(record.avg_crime) || 0,
score: parseFloat(record.score),
source_type: 'cbt',
source_type: "cbt",
});
}
@ -659,7 +662,7 @@ if (require.main === module) {
try {
await seeder.run();
} catch (e) {
console.error('Error during seeding:', e);
console.error("Error during seeding:", e);
process.exit(1);
} finally {
await prisma.$disconnect();