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, getCrimeByYearAndMonth,
getCrimeCategories, getCrimeCategories,
getCrimes, getCrimes,
getCrimesTypes,
getRecentIncidents,
} from '../action'; } from '../action';
export const useGetAvailableYears = () => { export const useGetAvailableYears = () => {
@ -36,3 +38,17 @@ export const useGetCrimeCategories = () => {
queryFn: () => getCrimeCategories(), 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 { import {
ICrimes, ICrimes,
ICrimesByYearAndMonth, ICrimesByYearAndMonth,
IDistanceResult, IDistanceResult,
} from '@/app/_utils/types/crimes'; IIncidentLogs,
import { getInjection } from '@/di/container'; } from "@/app/_utils/types/crimes";
import db from '@/prisma/db'; import { getInjection } from "@/di/container";
import db from "@/prisma/db";
import { import {
AuthenticationError, AuthenticationError,
UnauthenticatedError, UnauthenticatedError,
} from '@/src/entities/errors/auth'; } from "@/src/entities/errors/auth";
import { InputParseError } from '@/src/entities/errors/common'; import { InputParseError } from "@/src/entities/errors/common";
export async function getAvailableYears() { export async function getAvailableYears() {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction( return await instrumentationService.instrumentServerAction(
'Available Years', "Available Years",
{ recordResponse: true }, { recordResponse: true },
async () => { async () => {
try { try {
@ -26,50 +28,38 @@ export async function getAvailableYears() {
select: { select: {
year: true, year: true,
}, },
distinct: ['year'], distinct: ["year"],
orderBy: { orderBy: {
year: 'asc', year: "asc",
}, },
}); });
return years.map((year) => year.year); return years.map((year) => year.year);
} catch (err) { } catch (err) {
if (err instanceof InputParseError) { if (err instanceof InputParseError) {
// return {
// error: err.message,
// };
throw new InputParseError(err.message); throw new InputParseError(err.message);
} }
if (err instanceof AuthenticationError) { if (err instanceof AuthenticationError) {
// return {
// error: 'User not found.',
// };
throw new 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); crashReporterService.report(err);
// return {
// error:
// 'An error happened. The developers have been notified. Please try again later.',
// };
throw new Error( 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() { export async function getCrimeCategories() {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction( return await instrumentationService.instrumentServerAction(
'Crime Categories', "Crime Categories",
{ recordResponse: true }, { recordResponse: true },
async () => { async () => {
try { try {
@ -84,41 +74,29 @@ export async function getCrimeCategories() {
return categories; return categories;
} catch (err) { } catch (err) {
if (err instanceof InputParseError) { if (err instanceof InputParseError) {
// return {
// error: err.message,
// };
throw new InputParseError(err.message); throw new InputParseError(err.message);
} }
if (err instanceof AuthenticationError) { if (err instanceof AuthenticationError) {
// return {
// error: 'User not found.',
// };
throw new 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); crashReporterService.report(err);
// return {
// error:
// 'An error happened. The developers have been notified. Please try again later.',
// };
throw new Error( 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[]> { export async function getCrimes(): Promise<ICrimes[]> {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction( return await instrumentationService.instrumentServerAction(
'District Crime Data', "District Crime Data",
{ recordResponse: true }, { recordResponse: true },
async () => { async () => {
try { try {
@ -173,54 +151,121 @@ export async function getCrimes(): Promise<ICrimes[]> {
return crimes; return crimes;
} catch (err) { } catch (err) {
if (err instanceof InputParseError) { if (err instanceof InputParseError) {
// return {
// error: err.message,
// };
throw new InputParseError(err.message); throw new InputParseError(err.message);
} }
if (err instanceof AuthenticationError) { if (err instanceof AuthenticationError) {
// return {
// error: 'User not found.',
// };
throw new 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); crashReporterService.report(err);
// return {
// error:
// 'An error happened. The developers have been notified. Please try again later.',
// };
throw new Error( 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( export async function getCrimeByYearAndMonth(
year: number, year: number,
month: number | 'all' month: number | "all",
): Promise<ICrimesByYearAndMonth[]> { ): Promise<ICrimesByYearAndMonth[]> {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction( return await instrumentationService.instrumentServerAction(
'District Crime Data', "District Crime Data",
{ recordResponse: true }, { recordResponse: true },
async () => { async () => {
try { try {
// Build where clause conditionally based on provided parameters
const whereClause: any = { 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; whereClause.month = month;
} }
@ -231,7 +276,7 @@ export async function getCrimeByYearAndMonth(
select: { select: {
name: true, name: true,
geographics: { geographics: {
where: { year }, // Match geographics to selected year where: { year },
select: { select: {
address: true, address: true,
land_area: true, land_area: true,
@ -241,7 +286,7 @@ export async function getCrimeByYearAndMonth(
}, },
}, },
demographics: { demographics: {
where: { year }, // Match demographics to selected year where: { year },
select: { select: {
number_of_unemployed: true, number_of_unemployed: true,
population: 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) => { const processedCrimes = crimes.map((crime) => {
return { return {
...crime, ...crime,
districts: { districts: {
...crime.districts, ...crime.districts,
// Convert geographics array to single object matching the year
geographics: crime.districts.geographics[0] || null, geographics: crime.districts.geographics[0] || null,
// Convert demographics array to single object matching the year
demographics: crime.districts.demographics[0] || null, demographics: crime.districts.demographics[0] || null,
}, },
}; };
@ -297,17 +339,17 @@ export async function getCrimeByYearAndMonth(
if (err instanceof AuthenticationError) { if (err instanceof AuthenticationError) {
throw new 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); crashReporterService.report(err);
throw new Error( 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( export async function calculateDistances(
p_unit_id?: string, p_unit_id?: string,
p_district_id?: string p_district_id?: string,
): Promise<IDistanceResult[]> { ): Promise<IDistanceResult[]> {
const instrumentationService = getInjection('IInstrumentationService'); const instrumentationService = getInjection("IInstrumentationService");
return await instrumentationService.instrumentServerAction( return await instrumentationService.instrumentServerAction(
'Calculate Distances', "Calculate Distances",
{ recordResponse: true }, { recordResponse: true },
async () => { async () => {
const supabase = createClient(); const supabase = createClient();
try { try {
const { data, error } = await supabase.rpc( const { data, error } = await supabase.rpc(
'calculate_unit_incident_distances', "calculate_unit_incident_distances",
{ {
p_unit_id: p_unit_id || null, p_unit_id: p_unit_id || null,
p_district_id: p_district_id || null, p_district_id: p_district_id || null,
} },
); );
if (error) { if (error) {
console.error('Error calculating distances:', error); console.error("Error calculating distances:", error);
return []; return [];
} }
return data as IDistanceResult[] || []; return data as IDistanceResult[] || [];
} catch (error) { } catch (error) {
const crashReporterService = getInjection('ICrashReporterService'); const crashReporterService = getInjection("ICrashReporterService");
crashReporterService.report(error); crashReporterService.report(error);
console.error('Failed to calculate distances:', error); console.error("Failed to calculate distances:", error);
return []; 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" selectedMonth?: number | "all"
crimes: ICrimes[] crimes: ICrimes[]
isLoading?: boolean isLoading?: boolean
sourceType?: string
} }
export default function CrimeSidebar({ export default function CrimeSidebar({
@ -37,6 +38,7 @@ export default function CrimeSidebar({
selectedMonth, selectedMonth,
crimes = [], crimes = [],
isLoading = false, isLoading = false,
sourceType = "cbt",
}: CrimeSidebarProps) { }: CrimeSidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed) const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
const [activeTab, setActiveTab] = useState("incidents") const [activeTab, setActiveTab] = useState("incidents")
@ -60,13 +62,19 @@ export default function CrimeSidebar({
return () => clearInterval(timer) 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 // Format date with selected year and month if provided
const getDisplayDate = () => { const getDisplayDate = () => {
// If we have a specific month selected, use that for display
if (selectedMonth && selectedMonth !== 'all') { if (selectedMonth && selectedMonth !== 'all') {
const date = new Date() const date = new Date()
date.setFullYear(selectedYear) 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', { return new Intl.DateTimeFormat('en-US', {
year: 'numeric', year: 'numeric',
@ -74,7 +82,6 @@ export default function CrimeSidebar({
}).format(date) }).format(date)
} }
// Otherwise show today's date
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat('en-US', {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
@ -91,7 +98,6 @@ export default function CrimeSidebar({
hour12: true hour12: true
}).format(currentTime) }).format(currentTime)
// Generate a time period display for the current view
const getTimePeriodDisplay = () => { const getTimePeriodDisplay = () => {
if (selectedMonth && selectedMonth !== 'all') { if (selectedMonth && selectedMonth !== 'all') {
return `${getMonthName(Number(selectedMonth))} ${selectedYear}` return `${getMonthName(Number(selectedMonth))} ${selectedYear}`
@ -99,31 +105,8 @@ export default function CrimeSidebar({
return `${selectedYear} - All months` return `${selectedYear} - All months`
} }
// Function to fly to incident location when clicked
const handleIncidentClick = (incident: any) => { const handleIncidentClick = (incident: any) => {
if (!map || !incident.longitude || !incident.latitude) return 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 ( return (
@ -135,7 +118,6 @@ export default function CrimeSidebar({
<div className="relative h-full flex items-stretch"> <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="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"> <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"> <CardHeader className="p-0 pb-4 shrink-0 relative">
<div className="absolute top-0 right-0"> <div className="absolute top-0 right-0">
<Button <Button
@ -155,6 +137,11 @@ export default function CrimeSidebar({
<div> <div>
<CardTitle className="text-xl font-semibold"> <CardTitle className="text-xl font-semibold">
Crime Analysis Crime Analysis
{sourceType && (
<span className="ml-2 text-xs font-normal px-2 py-1 bg-sidebar-accent rounded-full">
{sourceType.toUpperCase()}
</span>
)}
</CardTitle> </CardTitle>
{!isLoading && ( {!isLoading && (
<CardDescription className="text-sm text-sidebar-foreground/70"> <CardDescription className="text-sm text-sidebar-foreground/70">
@ -165,7 +152,6 @@ export default function CrimeSidebar({
</div> </div>
</CardHeader> </CardHeader>
{/* Improved tabs with pill style */}
<Tabs <Tabs
defaultValue="incidents" defaultValue="incidents"
className="w-full flex-1 flex flex-col overflow-hidden" className="w-full flex-1 flex flex-col overflow-hidden"
@ -173,12 +159,14 @@ export default function CrimeSidebar({
onValueChange={setActiveTab} onValueChange={setActiveTab}
> >
<TabsList className="w-full mb-4 bg-sidebar-accent p-1 rounded-full"> <TabsList className="w-full mb-4 bg-sidebar-accent p-1 rounded-full">
<TabsTrigger <TabsTrigger
value="incidents" value="incidents"
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground" className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
> >
Dashboard Dashboard
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="statistics" value="statistics"
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground" 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> </div>
) : ( ) : (
<> <>
<TabsContent value="incidents" className="m-0 p-0 space-y-4"> <TabsContent value="incidents" className="m-0 p-0 space-y-4">
<SidebarIncidentsTab <SidebarIncidentsTab
crimeStats={crimeStats} crimeStats={crimeStats}
@ -226,6 +215,8 @@ export default function CrimeSidebar({
handleIncidentClick={handleIncidentClick} handleIncidentClick={handleIncidentClick}
activeIncidentTab={activeIncidentTab} activeIncidentTab={activeIncidentTab}
setActiveIncidentTab={setActiveIncidentTab} setActiveIncidentTab={setActiveIncidentTab}
sourceType={sourceType}
// setActiveTab={setActiveTab} // Pass setActiveTab function
/> />
</TabsContent> </TabsContent>
@ -234,11 +225,13 @@ export default function CrimeSidebar({
crimeStats={crimeStats} crimeStats={crimeStats}
selectedMonth={selectedMonth} selectedMonth={selectedMonth}
selectedYear={selectedYear} selectedYear={selectedYear}
sourceType={sourceType}
crimes={crimes}
/> />
</TabsContent> </TabsContent>
<TabsContent value="info" className="m-0 p-0 space-y-4"> <TabsContent value="info" className="m-0 p-0 space-y-4">
<SidebarInfoTab /> <SidebarInfoTab sourceType={sourceType} />
</TabsContent> </TabsContent>
</> </>
)} )}

View File

@ -1,5 +1,5 @@
import React from 'react' 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 { Card, CardContent } from "@/app/_components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
import { Badge } from "@/app/_components/ui/badge" import { Badge } from "@/app/_components/ui/badge"
@ -59,8 +59,9 @@ export function SidebarIncidentsTab({
handlePageChange, handlePageChange,
handleIncidentClick, handleIncidentClick,
activeIncidentTab, activeIncidentTab,
setActiveIncidentTab setActiveIncidentTab,
}: SidebarIncidentsTabProps) { sourceType = "cbt"
}: SidebarIncidentsTabProps & { sourceType?: string }) {
const topCategories = crimeStats.categoryCounts ? const topCategories = crimeStats.categoryCounts ?
Object.entries(crimeStats.categoryCounts) Object.entries(crimeStats.categoryCounts)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
@ -70,6 +71,58 @@ export function SidebarIncidentsTab({
return { type, count, percentage } 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 ( return (
<> <>
{/* Enhanced info card */} {/* Enhanced info card */}
@ -93,7 +146,7 @@ export function SidebarIncidentsTab({
<span className="text-sidebar-foreground/70">{location}</span> <span className="text-sidebar-foreground/70">{location}</span>
</div> </div>
<div className="flex items-center gap-2 bg-sidebar-accent/30 p-2 rounded-lg"> <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> <span>
<strong>{crimeStats.totalIncidents || 0}</strong> incidents reported <strong>{crimeStats.totalIncidents || 0}</strong> incidents reported
{selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`} {selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`}
@ -116,8 +169,8 @@ export function SidebarIncidentsTab({
<SystemStatusCard <SystemStatusCard
title="Recent Cases" title="Recent Cases"
status={`${crimeStats?.recentIncidents?.length || 0}`} status={`${crimeStats?.recentIncidents?.length || 0}`}
statusIcon={<Clock className="h-4 w-4 text-amber-400" />} statusIcon={<Clock className="h-4 w-4 text-emerald-400" />}
statusColor="text-amber-400" statusColor="text-emerald-400"
updatedTime="Last 30 days" updatedTime="Last 30 days"
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20" bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
borderColor="border-sidebar-border" borderColor="border-sidebar-border"
@ -243,7 +296,7 @@ export function SidebarIncidentsTab({
<div key={monthKey} className="mb-5"> <div key={monthKey} className="mb-5">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5"> <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> <h4 className="font-medium text-xs">{formatMonthKey(monthKey)}</h4>
</div> </div>
<Badge variant="secondary" className="h-5 text-[10px]"> <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 { CRIME_RATE_COLORS } from "@/app/_utils/const/map"
import { SidebarSection } from "../components/sidebar-section" import { SidebarSection } from "../components/sidebar-section"
export function SidebarInfoTab() { interface SidebarInfoTabProps {
sourceType?: string
}
export function SidebarInfoTab({ sourceType = "cbt" }: SidebarInfoTabProps) {
return ( return (
<> <>
<SidebarSection title="Map Legend" icon={<Layers className="h-4 w-4 text-green-400" />}> <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" /> <Separator className="bg-white/20 my-3" />
{/* Show different map markers based on source type */}
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium mb-2 text-sm">Map Markers</h4> <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"> {sourceType === "cbt" ? (
<AlertCircle className="h-4 w-4 text-red-500" /> // Detailed incidents for CBT
<span>Individual Incident</span> <>
</div> <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="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> <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> <span className="font-bold">Incident Cluster</span>
</div> </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> </div>
</CardContent> </CardContent>
</Card> </Card>
</SidebarSection> </SidebarSection>
{/* Show layers info based on source type */}
<SidebarSection title="Map Layers" icon={<Map className="h-4 w-4 text-blue-400" />}> <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"> <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"> <CardContent className="p-4 text-xs space-y-4">
<div className="space-y-2"> {sourceType === "cbt" ? (
<h4 className="flex items-center gap-2 font-medium text-sm"> // Show all layers for CBT
<AlertCircle className="h-4 w-4 text-cyan-400" /> <>
<span>Incidents Layer</span> <div className="space-y-2">
</h4> <h4 className="flex items-center gap-2 font-medium text-sm">
<p className="text-white/70 pl-6"> <AlertCircle className="h-4 w-4 text-cyan-400" />
Shows individual crime incidents as map markers. Each marker represents a single crime report and is color-coded by category. <span>Incidents Layer</span>
Click on any marker to see detailed information about the incident. </h4>
</p> <p className="text-white/70 pl-6">
</div> 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"> <div className="space-y-2">
<h4 className="flex items-center gap-2 font-medium text-sm"> <h4 className="flex items-center gap-2 font-medium text-sm">
<Box className="h-4 w-4 text-pink-400" /> <Box className="h-4 w-4 text-pink-400" />
<span>Clusters Layer</span> <span>Clusters Layer</span>
</h4> </h4>
<p className="text-white/70 pl-6"> <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. 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). Clusters are color-coded by size: blue (small), yellow (medium), pink (large).
Click on a cluster to zoom in and see individual incidents. Click on a cluster to zoom in and see individual incidents.
</p> </p>
<div className="flex items-center gap-6 pl-6 pt-1"> <div className="flex items-center gap-6 pl-6 pt-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-[#51bbd6]"></div> <div className="w-4 h-4 rounded-full bg-[#51bbd6]"></div>
<span>1-5</span> <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>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-[#f1f075]"></div> <div className="space-y-2">
<span>6-15</span> <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>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded-full bg-[#f28cb1]"></div> <div className="space-y-2">
<span>15+</span> <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>
</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> </CardContent>
</Card> </Card>
</SidebarSection> </SidebarSection>
@ -170,6 +226,12 @@ export function SidebarInfoTab() {
<span>Last Updated</span> <span>Last Updated</span>
<span className="font-medium">June 18, 2024</span> <span className="font-medium">June 18, 2024</span>
</div> </div>
{sourceType && (
<div className="flex justify-between mt-1">
<span>Data Source</span>
<span className="font-medium">{sourceType.toUpperCase()}</span>
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -203,17 +265,20 @@ export function SidebarInfoTab() {
</div> </div>
</div> </div>
<div className="flex gap-3 items-start"> {/* Show incident details help only for CBT */}
<div className="bg-emerald-900/50 p-1.5 rounded-md"> {sourceType === "cbt" && (
<AlertTriangle className="h-3.5 w-3.5 text-emerald-400" /> <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>
<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> </CardContent>
</Card> </Card>
</SidebarSection> </SidebarSection>

View File

@ -1,5 +1,5 @@
import React from 'react' 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card"
import { Separator } from "@/app/_components/ui/separator" import { Separator } from "@/app/_components/ui/separator"
import { cn } from "@/app/_lib/utils" 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 { CrimeTypeCard, ICrimeTypeCardProps } from "../components/crime-type-card"
import { ICrimeAnalytics } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics' import { ICrimeAnalytics } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics'
import { MONTHS } from '@/app/_utils/const/common' import { MONTHS } from '@/app/_utils/const/common'
import { ICrimes } from '@/app/_utils/types/crimes'
import { Button } from "@/app/_components/ui/button"
interface ISidebarStatisticsTabProps { interface ISidebarStatisticsTabProps {
crimeStats: ICrimeAnalytics crimeStats: ICrimeAnalytics
selectedMonth?: number | "all" selectedMonth?: number | "all"
selectedYear: number 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({ export function SidebarStatisticsTab({
crimeStats, crimeStats,
selectedMonth = "all", selectedMonth = "all",
selectedYear selectedYear,
}: ISidebarStatisticsTabProps) { sourceType = "cbt",
crimes = []
}: ISidebarStatisticsTabProps & { crimes?: ICrimes[] }) {
const topCategories = crimeStats.categoryCounts ? const topCategories = crimeStats.categoryCounts ?
Object.entries(crimeStats.categoryCounts) Object.entries(crimeStats.categoryCounts)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
@ -31,8 +287,64 @@ export function SidebarStatisticsTab({
return { type, count, percentage } 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 ( return (
<> <>
<Card className="bg-gradient-to-r from-sidebar-primary/30 to-sidebar-primary/20 border border-sidebar-primary/20 overflow-hidden"> <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"> <CardHeader className="p-3 pb-0">
<CardTitle className="text-sm font-medium flex items-center gap-2"> <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> <CardDescription className="text-xs text-white/60">{selectedYear}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-3"> <CardContent className="p-3">
<div className="h-32 flex items-end gap-1 mt-2"> <MonthlyTrendChart monthlyData={crimeStats.incidentsByMonth} />
{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>
<div className="flex justify-between mt-2 text-[10px] text-white/60"> <div className="flex justify-between mt-2 text-[10px] text-white/60">
{MONTHS.map((month, i) => ( {MONTHS.map((month, i) => (
<span key={i} className={cn("w-1/12 text-center", selectedMonth !== 'all' && i + 1 === Number(selectedMonth) ? "text-amber-400" : "")}> <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> </div>
</SidebarSection> </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" /> <Separator className="bg-white/20 my-4" />
<SidebarSection title="Most Common Crimes" icon={<PieChart className="h-4 w-4 text-amber-400" />}> <TimeOfDayDistribution crimes={crimes} sourceType={sourceType} />
<div className="space-y-3">
{topCategories.length > 0 ? ( <div className="mt-4">
topCategories.map((category: ICrimeTypeCardProps) => ( <DistrictDistribution crimes={crimes} sourceType={sourceType} />
<CrimeTypeCard </div>
key={category.type}
type={category.type} {sourceType === "cbt" && (
count={category.count} <>
percentage={category.percentage} <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">
<Card className="bg-white/5 border-0 text-white shadow-none"> {topCategories.length > 0 ? (
<CardContent className="p-4 text-center"> topCategories.map((category: ICrimeTypeCardProps) => (
<div className="flex flex-col items-center gap-2"> <CrimeTypeCard
<FileText className="h-6 w-6 text-white/40" /> key={category.type}
<p className="text-sm text-white/70">No crime data available</p> type={category.type}
<p className="text-xs text-white/50">Try selecting a different time period</p> count={category.count}
</div> percentage={category.percentage}
</CardContent> />
</Card> ))
)} ) : (
</div> <Card className="bg-white/5 border-0 text-white shadow-none">
</SidebarSection> <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 { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
import { Popover, PopoverContent, PopoverTrigger } from "@/app/_components/ui/popover" 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 { IconMessage } from "@tabler/icons-react"
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { ITooltips } from "./tooltips" import type { ITooltips } from "./tooltips"
import MonthSelector from "../month-selector" import MonthSelector from "../month-selector"
import YearSelector from "../year-selector" import YearSelector from "../year-selector"
import CategorySelector from "../category-selector" import CategorySelector from "../category-selector"
import SourceTypeSelector from "../source-type-selector"
// Define the additional tools and features // Define the additional tools and features
const additionalTooltips = [ const additionalTooltips = [
{ id: "reports" as ITooltips, icon: <IconMessage size={20} />, label: "Police Report" }, { 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" }, { id: "alerts" as ITooltips, icon: <Siren size={20} />, label: "Active Alerts" },
] ]
@ -28,7 +28,10 @@ interface AdditionalTooltipsProps {
setSelectedMonth: (month: number | "all") => void setSelectedMonth: (month: number | "all") => void
selectedCategory: string | "all" selectedCategory: string | "all"
setSelectedCategory: (category: string | "all") => void setSelectedCategory: (category: string | "all") => void
selectedSourceType: string
setSelectedSourceType: (sourceType: string) => void
availableYears?: (number | null)[] availableYears?: (number | null)[]
availableSourceTypes?: string[]
categories?: string[] categories?: string[]
panicButtonTriggered?: boolean panicButtonTriggered?: boolean
} }
@ -42,7 +45,10 @@ export default function AdditionalTooltips({
setSelectedMonth, setSelectedMonth,
selectedCategory, selectedCategory,
setSelectedCategory, setSelectedCategory,
availableYears = [2022, 2023, 2024], selectedSourceType = "cbu",
setSelectedSourceType,
availableYears = [],
availableSourceTypes = [],
categories = [], categories = [],
panicButtonTriggered = false, panicButtonTriggered = false,
}: AdditionalTooltipsProps) { }: AdditionalTooltipsProps) {
@ -54,110 +60,138 @@ export default function AdditionalTooltips({
useEffect(() => { useEffect(() => {
if (panicButtonTriggered && activeControl !== "alerts" && onControlChange) { if (panicButtonTriggered && activeControl !== "alerts" && onControlChange) {
onControlChange("alerts"); onControlChange("alerts")
} }
}, [panicButtonTriggered, activeControl, onControlChange]); }, [panicButtonTriggered, activeControl, onControlChange])
useEffect(() => { 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 ( return (
<> <>
<div ref={containerRef} className="z-10 bg-background rounded-md p-1 flex items-center space-x-1"> <div ref={containerRef} className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider> <TooltipProvider>
{additionalTooltips.map((control) => ( {additionalTooltips.map((control) => {
<Tooltip key={control.id}> const isButtonDisabled = isControlDisabled(control.id)
<TooltipTrigger asChild>
<Button return (
variant={activeControl === control.id ? "default" : "ghost"} <Tooltip key={control.id}>
size="medium" <TooltipTrigger asChild>
className={`h-8 w-8 rounded-md ${activeControl === control.id <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" ? "bg-emerald-500 text-black hover:bg-emerald-500/90"
: "text-white hover:bg-emerald-500/90 hover:text-background" : "text-white hover:bg-emerald-500/90 hover:text-background"
} ${control.id === "alerts" && panicButtonTriggered ? "animate-pulse ring-2 ring-red-500" : ""}`} } ${control.id === "alerts" && panicButtonTriggered ? "animate-pulse ring-2 ring-red-500" : ""}`}
onClick={() => onControlChange?.(control.id)} onClick={() => onControlChange?.(control.id)}
> disabled={isButtonDisabled}
{control.icon} aria-disabled={isButtonDisabled}
<span className="sr-only">{control.label}</span> >
</Button> {control.icon}
</TooltipTrigger> <span className="sr-only">{control.label}</span>
<TooltipContent side="bottom"> </Button>
<p>{control.label}</p> </TooltipTrigger>
</TooltipContent> <TooltipContent side="bottom">
</Tooltip> <p>{isButtonDisabled ? "Not available for CBU data" : control.label}</p>
))} </TooltipContent>
</Tooltip>
)
})}
<Tooltip> <Tooltip>
<Popover open={showSelectors} onOpenChange={setShowSelectors}> <Popover open={showSelectors} onOpenChange={setShowSelectors}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 rounded-md text-white hover:bg-emerald-500/90 hover:text-background" className="h-8 w-8 rounded-md text-white hover:bg-emerald-500/90 hover:text-background"
onClick={() => setShowSelectors(!showSelectors)} onClick={() => setShowSelectors(!showSelectors)}
> >
<ChevronDown size={20} /> <ChevronDown size={20} />
<span className="sr-only">Filters</span> <span className="sr-only">Filters</span>
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
container={containerRef.current || container || undefined} container={containerRef.current || container || undefined}
className="w-auto p-3 bg-black/90 border-gray-700 text-white" className="w-auto p-3 bg-black/90 border-gray-700 text-white"
align="end" align="end"
style={{ zIndex: 2000 }} style={{ zIndex: 2000 }}
> >
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs w-16">Year:</span> <span className="text-xs w-16">Source:</span>
<YearSelector <SourceTypeSelector
availableYears={availableYears} availableSourceTypes={availableSourceTypes}
selectedYear={selectedYear} selectedSourceType={selectedSourceType}
onYearChange={setSelectedYear} onSourceTypeChange={setSelectedSourceType}
className="w-[180px]" className="w-[180px]"
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs w-16">Month:</span> <span className="text-xs w-16">Year:</span>
<MonthSelector <YearSelector
selectedMonth={selectedMonth} availableYears={availableYears}
onMonthChange={setSelectedMonth} selectedYear={selectedYear}
className="w-[180px]" onYearChange={setSelectedYear}
/> className="w-[180px]"
</div> />
<div className="flex items-center gap-2"> </div>
<span className="text-xs w-16">Category:</span> <div className="flex items-center gap-2">
<CategorySelector <span className="text-xs w-16">Month:</span>
categories={categories} <MonthSelector
selectedCategory={selectedCategory} selectedMonth={selectedMonth}
onCategoryChange={setSelectedCategory} onMonthChange={setSelectedMonth}
className="w-[180px]" className="w-[180px]"
/> />
</div> </div>
</div> <div className="flex items-center gap-2">
</PopoverContent> <span className="text-xs w-16">Category:</span>
</Popover> <CategorySelector
</Tooltip> categories={categories}
</TooltipProvider> selectedCategory={selectedCategory}
</div> onCategoryChange={setSelectedCategory}
className="w-[180px]"
/>
</div>
</div>
</PopoverContent>
</Popover>
</Tooltip>
</TooltipProvider>
</div>
{showSelectors && ( {showSelectors && (
<div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden"> <div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden">
<YearSelector <SourceTypeSelector
availableYears={availableYears} availableSourceTypes={availableSourceTypes}
selectedYear={selectedYear} selectedSourceType={selectedSourceType}
onYearChange={setSelectedYear} onSourceTypeChange={setSelectedSourceType}
className="w-[100px]" className="w-[80px]"
/> />
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[100px]" /> <YearSelector
<CategorySelector availableYears={availableYears}
categories={categories} selectedYear={selectedYear}
selectedCategory={selectedCategory} onYearChange={setSelectedYear}
onCategoryChange={setSelectedCategory} className="w-[80px]"
className="w-[100px]" />
/> <MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[80px]" />
</div> <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 { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
import { AlertTriangle, BarChart2, Building, Car, ChartScatter, Clock, Thermometer, Shield, Users } from "lucide-react" import { AlertTriangle, Building, Car, Thermometer } from "lucide-react"
import { ITooltips } from "./tooltips" import type { ITooltips } from "./tooltips"
import { IconBubble, IconChartBubble, IconClock } from "@tabler/icons-react" import { IconChartBubble, IconClock } from "@tabler/icons-react"
// Define the primary crime data controls // Define the primary crime data controls
const crimeTooltips = [ const crimeTooltips = [
{ id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" }, { id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" },
{ id: "heatmap" as ITooltips, icon: <Thermometer size={20} />, label: "Density Heatmap" }, { id: "heatmap" as ITooltips, icon: <Thermometer size={20} />, label: "Density Heatmap" },
{ id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" }, { 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: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" },
{ id: "timeline" as ITooltips, icon: <IconClock size={20} />, label: "Time Analysis" }, { id: "timeline" as ITooltips, icon: <IconClock size={20} />, label: "Time Analysis" },
] ]
@ -20,43 +19,61 @@ const crimeTooltips = [
interface CrimeTooltipsProps { interface CrimeTooltipsProps {
activeControl?: string activeControl?: string
onControlChange?: (controlId: ITooltips) => void 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) => { const handleControlClick = (controlId: ITooltips) => {
console.log("Clicked control:", controlId); // If control is disabled, don't do anything
// Force the value to be set when clicking if (isDisabled(controlId)) {
if (onControlChange) { return
onControlChange(controlId); }
console.log("Control changed to:", controlId);
} // 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 ( return (
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1"> <div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
<TooltipProvider> <TooltipProvider>
{crimeTooltips.map((control) => ( {crimeTooltips.map((control) => {
<Tooltip key={control.id}> const isButtonDisabled = isDisabled(control.id)
<TooltipTrigger asChild>
<Button return (
variant={activeControl === control.id ? "default" : "ghost"} <Tooltip key={control.id}>
size="medium" <TooltipTrigger asChild>
className={`h-8 w-8 rounded-md ${activeControl === control.id <Button
? "bg-emerald-500 text-black hover:bg-emerald-500/90" variant={activeControl === control.id ? "default" : "ghost"}
: "text-white hover:bg-emerald-500/90 hover:text-background" size="medium"
}`} className={`h-8 w-8 rounded-md ${isButtonDisabled
onClick={() => handleControlClick(control.id)} ? "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
{control.icon} ? "bg-emerald-500 text-black hover:bg-emerald-500/90"
<span className="sr-only">{control.label}</span> : "text-white hover:bg-emerald-500/90 hover:text-background"
</Button> }`}
</TooltipTrigger> onClick={() => handleControlClick(control.id)}
<TooltipContent side="bottom"> disabled={isButtonDisabled}
<p>{control.label}</p> aria-disabled={isButtonDisabled}
</TooltipContent> >
</Tooltip> {control.icon}
))} <span className="sr-only">{control.label}</span>
</TooltipProvider> </Button>
</div> </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 { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip" 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 { useEffect, useRef, useState } from "react"
import { AnimatePresence, motion } from "framer-motion" import { AnimatePresence, motion } from "framer-motion"
import ActionSearchBar from "@/app/_components/action-search-bar" import ActionSearchBar from "@/app/_components/action-search-bar"
import { Card } from "@/app/_components/ui/card" import { Card } from "@/app/_components/ui/card"
import { format } from 'date-fns' import { format } from "date-fns"
import { ITooltips } from "./tooltips" import type { ITooltips } from "./tooltips"
import { $Enums } from "@prisma/client"
// Define types based on the crime data structure // Define types based on the crime data structure
interface ICrimeIncident { interface ICrimeIncident {
@ -19,10 +29,10 @@ interface ICrimeIncident {
description: string description: string
status: string status: string
locations: { locations: {
address: string; address: string
longitude: number; longitude: number
latitude: number; latitude: number
}, }
crime_categories: { crime_categories: {
id: string id: string
name: string name: string
@ -88,9 +98,15 @@ interface SearchTooltipProps {
onControlChange?: (controlId: ITooltips) => void onControlChange?: (controlId: ITooltips) => void
activeControl?: string activeControl?: string
crimes?: ICrime[] 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 [showSearch, setShowSearch] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null) const searchInputRef = useRef<HTMLInputElement>(null)
const [selectedSearchType, setSelectedSearchType] = useState<string | null>(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 [selectedSuggestion, setSelectedSuggestion] = useState<ICrimeIncident | null>(null)
const [showInfoBox, setShowInfoBox] = useState(false) const [showInfoBox, setShowInfoBox] = useState(false)
// Check if search is disabled based on source type
const isSearchDisabled = sourceType === "cbu"
// Limit results to prevent performance issues // Limit results to prevent performance issues
const MAX_RESULTS = 50; const MAX_RESULTS = 50
// Extract all incidents from crimes data // Extract all incidents from crimes data
const allIncidents = crimes.flatMap(crime => const allIncidents = crimes.flatMap((crime) =>
crime.crime_incidents.map(incident => ({ crime.crime_incidents.map((incident) => ({
...incident, ...incident,
district: crime.districts?.name || '', district: crime.districts?.name || "",
year: crime.year, year: crime.year,
month: crime.month month: crime.month,
})) })),
) )
useEffect(() => { useEffect(() => {
if (showSearch && searchInputRef.current) { if (showSearch && searchInputRef.current) {
setTimeout(() => { setTimeout(() => {
searchInputRef.current?.focus(); searchInputRef.current?.focus()
}, 100); }, 100)
} }
}, [showSearch]); }, [showSearch])
const handleSearchTypeSelect = (actionId: string) => { const handleSearchTypeSelect = (actionId: string) => {
const selectedAction = ACTIONS.find(action => action.id === actionId); const selectedAction = ACTIONS.find((action) => action.id === actionId)
if (selectedAction) { if (selectedAction) {
setSelectedSearchType(actionId); setSelectedSearchType(actionId)
const prefix = selectedAction.prefix || ""; const prefix = selectedAction.prefix || ""
setSearchValue(prefix); setSearchValue(prefix)
setIsInputValid(true); setIsInputValid(true)
// Initial suggestions based on the selected search type // Initial suggestions based on the selected search type
let initialSuggestions: ICrimeIncident[] = []; let initialSuggestions: ICrimeIncident[] = []
if (actionId === 'incident_id') { if (actionId === "incident_id") {
initialSuggestions = allIncidents.slice(0, MAX_RESULTS); // Limit to 50 results initially initialSuggestions = allIncidents.slice(0, MAX_RESULTS) // Limit to 50 results initially
} else if (actionId === 'description' || actionId === 'locations.address') { } else if (actionId === "description" || actionId === "locations.address") {
initialSuggestions = allIncidents.slice(0, MAX_RESULTS); 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);
} }
}
// 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 // Filter suggestions based on search type and search text
const filterSuggestions = (searchType: string, searchText: string): ICrimeIncident[] => { const filterSuggestions = (searchType: string, searchText: string): ICrimeIncident[] => {
let filtered: ICrimeIncident[] = []; let filtered: ICrimeIncident[] = []
if (searchType === 'incident_id') { if (searchType === "incident_id") {
if (!searchText || searchText === 'CI-') { if (!searchText || searchText === "CI-") {
filtered = allIncidents.slice(0, MAX_RESULTS); filtered = allIncidents.slice(0, MAX_RESULTS)
} else { } else {
filtered = allIncidents.filter(item => filtered = allIncidents
item.id.toLowerCase().includes(searchText.toLowerCase()) .filter((item) => item.id.toLowerCase().includes(searchText.toLowerCase()))
).slice(0, MAX_RESULTS); .slice(0, MAX_RESULTS)
} }
} } else if (searchType === "description") {
else if (searchType === 'description') { if (!searchText) {
if (!searchText) { filtered = allIncidents.slice(0, MAX_RESULTS)
filtered = allIncidents.slice(0, MAX_RESULTS); } else {
} else { filtered = allIncidents
filtered = allIncidents.filter(item => .filter((item) => item.description.toLowerCase().includes(searchText.toLowerCase()))
item.description.toLowerCase().includes(searchText.toLowerCase()) .slice(0, MAX_RESULTS)
).slice(0, MAX_RESULTS); }
} } else if (searchType === "locations.address") {
} if (!searchText) {
else if (searchType === 'locations.address') { filtered = allIncidents.slice(0, MAX_RESULTS)
if (!searchText) { } else {
filtered = allIncidents.slice(0, MAX_RESULTS); filtered = allIncidents
} else { .filter(
filtered = allIncidents.filter(item => (item) => item.locations.address && item.locations.address.toLowerCase().includes(searchText.toLowerCase()),
item.locations.address && item.locations.address.toLowerCase().includes(searchText.toLowerCase()) )
).slice(0, MAX_RESULTS); .slice(0, MAX_RESULTS)
} }
} } else if (searchType === "coordinates") {
else if (searchType === 'coordinates') { if (!searchText) {
if (!searchText) { filtered = allIncidents
filtered = allIncidents.filter(item => item.locations.latitude !== undefined && item.locations.longitude !== undefined) .filter((item) => item.locations.latitude !== undefined && item.locations.longitude !== undefined)
.slice(0, MAX_RESULTS); .slice(0, MAX_RESULTS)
} else { } else {
// For coordinates, we'd typically do a proximity search // For coordinates, we'd typically do a proximity search
// This is a simple implementation for demo purposes // This is a simple implementation for demo purposes
filtered = allIncidents.filter(item => filtered = allIncidents
item.locations.latitude !== undefined && .filter(
item.locations.longitude !== undefined && (item) =>
`${item.locations.latitude}, ${item.locations.longitude}`.includes(searchText) item.locations.latitude !== undefined &&
).slice(0, MAX_RESULTS); 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 handleSearchChange = (value: string) => {
const currentSearchType = selectedSearchType ? const currentSearchType = selectedSearchType ? ACTIONS.find((action) => action.id === selectedSearchType) : null
ACTIONS.find(action => action.id === selectedSearchType) : null;
if (currentSearchType?.prefix && currentSearchType.prefix.length > 0) { if (currentSearchType?.prefix && currentSearchType.prefix.length > 0) {
if (!value.startsWith(currentSearchType.prefix)) { if (!value.startsWith(currentSearchType.prefix)) {
value = 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 = () => { const handleClearSearchType = () => {
setSelectedSearchType(null); setSelectedSearchType(null)
setSearchValue(""); setSearchValue("")
setSuggestions([]); setSuggestions([])
if (searchInputRef.current) { if (searchInputRef.current) {
setTimeout(() => { setTimeout(() => {
searchInputRef.current?.focus(); searchInputRef.current?.focus()
}, 50); }, 50)
} }
}; }
const handleSuggestionSelect = (incident: ICrimeIncident) => { const handleSuggestionSelect = (incident: ICrimeIncident) => {
setSearchValue(incident.id); setSearchValue(incident.id)
setSuggestions([]); setSuggestions([])
setSelectedSuggestion(incident); setSelectedSuggestion(incident)
setShowInfoBox(true); setShowInfoBox(true)
}; }
const handleFlyToIncident = () => { 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 // Dispatch mapbox_fly_to event to the main map canvas only
const flyToMapEvent = new CustomEvent('mapbox_fly_to', { 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: { detail: {
id: selectedSuggestion.id,
longitude: selectedSuggestion.locations.longitude, longitude: selectedSuggestion.locations.longitude,
latitude: selectedSuggestion.locations.latitude, latitude: selectedSuggestion.locations.latitude,
zoom: 15, description: selectedSuggestion.description,
bearing: 0, status: selectedSuggestion.status,
pitch: 45, timestamp: selectedSuggestion.timestamp,
duration: 2000, crime_categories: selectedSuggestion.crime_categories,
}, },
bubbles: true bubbles: true,
}); })
// Find the main map canvas and dispatch event there document.dispatchEvent(incidentEvent)
const mapCanvas = document.querySelector('.mapboxgl-canvas'); }, 2100) // Slightly longer than the fly animation duration
if (mapCanvas) {
mapCanvas.dispatchEvent(flyToMapEvent);
}
// Wait for the fly animation to complete before showing the popup setShowInfoBox(false)
setTimeout(() => { setSelectedSuggestion(null)
// Then trigger the incident_click event to show the popup toggleSearch()
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();
};
const handleCloseInfoBox = () => { const handleCloseInfoBox = () => {
setShowInfoBox(false); setShowInfoBox(false)
setSelectedSuggestion(null); setSelectedSuggestion(null)
// Restore original suggestions // Restore original suggestions
if (selectedSearchType) { if (selectedSearchType) {
const initialSuggestions = filterSuggestions(selectedSearchType, searchValue); const initialSuggestions = filterSuggestions(selectedSearchType, searchValue)
setSuggestions(initialSuggestions); setSuggestions(initialSuggestions)
} }
}; }
const toggleSearch = () => { const toggleSearch = () => {
setShowSearch(!showSearch) if (isSearchDisabled) return
if (!showSearch && onControlChange) {
onControlChange("search" as ITooltips) setShowSearch(!showSearch)
setSelectedSearchType(null); if (!showSearch && onControlChange) {
setSearchValue(""); onControlChange("search" as ITooltips)
setSuggestions([]); setSelectedSearchType(null)
} setSearchValue("")
} setSuggestions([])
}
}
// Format date for display // Format date for display
const formatIncidentDate = (incident: ICrimeIncident) => { const formatIncidentDate = (incident: ICrimeIncident) => {
try { try {
if (incident.timestamp) { if (incident.timestamp) {
return format(new Date(incident.timestamp), 'PPP p'); return format(new Date(incident.timestamp), "PPP p")
} }
return 'N/A'; return "N/A"
} catch (error) { } catch (error) {
return 'Invalid date'; return "Invalid date"
} }
}; }
return ( return (
<> <>
@ -341,223 +364,222 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
<Button <Button
variant={showSearch ? "default" : "ghost"} variant={showSearch ? "default" : "ghost"}
size="medium" size="medium"
className={`h-8 w-8 rounded-md ${showSearch className={`h-8 w-8 rounded-md ${isSearchDisabled
? "bg-emerald-500 text-black hover:bg-emerald-500/90" ? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
: "text-white hover:bg-emerald-500/90 hover:text-background" : showSearch
}`} ? "bg-emerald-500 text-black hover:bg-emerald-500/90"
onClick={toggleSearch} : "text-white hover:bg-emerald-500/90 hover:text-background"
> }`}
<Search size={20} /> onClick={toggleSearch}
<span className="sr-only">Search Incidents</span> disabled={isSearchDisabled}
</Button> aria-disabled={isSearchDisabled}
</TooltipTrigger> >
<TooltipContent side="bottom"> <Search size={20} />
<p>Search Incidents</p> <span className="sr-only">Search Incidents</span>
</TooltipContent> </Button>
</Tooltip> </TooltipTrigger>
</TooltipProvider> <TooltipContent side="bottom">
</div> <p>{isSearchDisabled ? "Not available for CBU data" : "Search Incidents"}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<AnimatePresence> <AnimatePresence>
{showSearch && ( {showSearch && (
<> <>
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center" className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center"
onClick={toggleSearch} onClick={toggleSearch}
/> />
<motion.div <motion.div
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} 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" 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="bg-background border border-border rounded-lg shadow-xl p-4">
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-medium">Search Incidents</h3> <h3 className="text-lg font-medium">Search Incidents</h3>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 rounded-full hover:bg-emerald-500/90 hover:text-background" className="h-7 w-7 rounded-full hover:bg-emerald-500/90 hover:text-background"
onClick={toggleSearch} onClick={toggleSearch}
> >
<XCircle size={18} /> <XCircle size={18} />
</Button> </Button>
</div> </div>
{!showInfoBox ? ( {!showInfoBox ? (
<> <>
<ActionSearchBar <ActionSearchBar
ref={searchInputRef} ref={searchInputRef}
autoFocus autoFocus
isFloating isFloating
defaultActions={false} defaultActions={false}
actions={ACTIONS} actions={ACTIONS}
onActionSelect={handleSearchTypeSelect} onActionSelect={handleSearchTypeSelect}
onClearAction={handleClearSearchType} onClearAction={handleClearSearchType}
activeActionId={selectedSearchType} activeActionId={selectedSearchType}
value={searchValue} value={searchValue}
onChange={handleSearchChange} onChange={handleSearchChange}
placeholder={selectedSearchType ? placeholder={
ACTIONS.find(a => a.id === selectedSearchType)?.placeholder : selectedSearchType
"Select a search type..."} ? ACTIONS.find((a) => a.id === selectedSearchType)?.placeholder
inputClassName={!isInputValid ? : "Select a search type..."
"border-destructive focus-visible:ring-destructive bg-destructive/50" : ""} }
/> inputClassName={
!isInputValid ? "border-destructive focus-visible:ring-destructive bg-destructive/50" : ""
}
/>
{!isInputValid && selectedSearchType && ( {!isInputValid && selectedSearchType && (
<div className="mt-1 text-xs text-destructive"> <div className="mt-1 text-xs text-destructive">
Invalid format. {ACTIONS.find(a => a.id === selectedSearchType)?.description} Invalid format. {ACTIONS.find((a) => a.id === selectedSearchType)?.description}
</div> </div>
)} )}
{(suggestions.length > 0 && selectedSearchType) && ( {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="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"> <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"> <p className="text-xs text-muted-foreground">
{suggestions.length} results found {suggestions.length} results found
{suggestions.length === 50 && " (showing top 50)"} {suggestions.length === 50 && " (showing top 50)"}
</p> </p>
</div> </div>
<ul className="py-1"> <ul className="py-1">
{suggestions.map((incident, index) => ( {suggestions.map((incident, index) => (
<li <li
key={index} key={index}
className="px-3 py-2 hover:bg-muted cursor-pointer flex items-center justify-between" className="px-3 py-2 hover:bg-muted cursor-pointer flex items-center justify-between"
onClick={() => handleSuggestionSelect(incident)} onClick={() => handleSuggestionSelect(incident)}
> >
<span className="font-medium">{incident.id}</span> <span className="font-medium">{incident.id}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedSearchType === 'incident_id' ? ( {selectedSearchType === "incident_id" ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]"> <span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.description} {incident.description}
</span> </span>
) : selectedSearchType === 'coordinates' ? ( ) : selectedSearchType === "coordinates" ? (
<span className="text-muted-foreground text-sm truncate max-w-[300px]"> <span className="text-muted-foreground text-sm truncate max-w-[300px]">
{incident.locations.latitude}, {incident.locations.longitude} - {incident.description} {incident.locations.latitude}, {incident.locations.longitude} -{" "}
</span> {incident.description}
) : selectedSearchType === 'locations.address' ? ( </span>
<span className="text-muted-foreground text-sm truncate max-w-[300px]"> ) : selectedSearchType === "locations.address" ? (
{incident.locations.address || 'N/A'} <span className="text-muted-foreground text-sm truncate max-w-[300px]">
</span> {incident.locations.address || "N/A"}
) : ( </span>
<span className="text-muted-foreground text-sm truncate max-w-[300px]"> ) : (
{incident.description} <span className="text-muted-foreground text-sm truncate max-w-[300px]">
</span> {incident.description}
)} </span>
<Button )}
variant="ghost" <Button
size="sm" variant="ghost"
className="h-6 w-6 p-0 rounded-full hover:bg-muted" size="sm"
onClick={(e) => { className="h-6 w-6 p-0 rounded-full hover:bg-muted"
e.stopPropagation(); onClick={(e) => {
handleSuggestionSelect(incident); e.stopPropagation()
}} handleSuggestionSelect(incident)
> }}
<Info className="h-3.5 w-3.5 text-muted-foreground" /> >
</Button> <Info className="h-3.5 w-3.5 text-muted-foreground" />
</div> </Button>
</li> </div>
))} </li>
</ul> ))}
</div> </ul>
)} </div>
)}
{selectedSearchType && searchValue.length > (ACTIONS.find(a => a.id === selectedSearchType)?.prefix?.length || 0) && {selectedSearchType &&
suggestions.length === 0 && ( searchValue.length > (ACTIONS.find((a) => a.id === selectedSearchType)?.prefix?.length || 0) &&
<div className="mt-2 p-3 border border-border rounded-md bg-background/80 text-center"> suggestions.length === 0 && (
<p className="text-sm text-muted-foreground">No matching incidents found</p> <div className="mt-2 p-3 border border-border rounded-md bg-background/80 text-center">
</div> <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"> <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"> <p className="flex items-center text-sm text-muted-foreground">
{selectedSearchType ? ( {selectedSearchType ? (
<> <>
<span className="mr-1">{ACTIONS.find(a => a.id === selectedSearchType)?.icon}</span> <span className="mr-1">{ACTIONS.find((a) => a.id === selectedSearchType)?.icon}</span>
<span> <span>{ACTIONS.find((a) => a.id === selectedSearchType)?.description}</span>
{ACTIONS.find(a => a.id === selectedSearchType)?.description} </>
</span> ) : (
</> <span>Select a search type and enter your search criteria</span>
) : ( )}
<span> </p>
Select a search type and enter your search criteria </div>
</span> </>
)} ) : (
</p> <Card className="p-4 border border-border">
</div> <div className="flex justify-between items-start mb-4">
</> <h3 className="text-lg font-semibold">{selectedSuggestion?.id}</h3>
) : ( </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 && ( {selectedSuggestion && (
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-[20px_1fr] gap-2 items-start"> <div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<Info className="h-4 w-4 mt-1 text-muted-foreground" /> <Info className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{selectedSuggestion.description}</p> <p className="text-sm">{selectedSuggestion.description}</p>
</div> </div>
{selectedSuggestion.timestamp && ( {selectedSuggestion.timestamp && (
<div className="grid grid-cols-[20px_1fr] gap-2 items-start"> <div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<Calendar className="h-4 w-4 mt-1 text-muted-foreground" /> <Calendar className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm"> <p className="text-sm">{formatIncidentDate(selectedSuggestion)}</p>
{formatIncidentDate(selectedSuggestion)} </div>
</p> )}
</div>
)}
{selectedSuggestion.locations.address && ( {selectedSuggestion.locations.address && (
<div className="grid grid-cols-[20px_1fr] gap-2 items-start"> <div className="grid grid-cols-[20px_1fr] gap-2 items-start">
<MapPin className="h-4 w-4 mt-1 text-muted-foreground" /> <MapPin className="h-4 w-4 mt-1 text-muted-foreground" />
<p className="text-sm">{selectedSuggestion.locations.address}</p> <p className="text-sm">{selectedSuggestion.locations.address}</p>
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-2 mt-2"> <div className="grid grid-cols-2 gap-2 mt-2">
<div className="bg-muted/50 rounded p-2"> <div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground font-medium">Category</p> <p className="text-xs text-muted-foreground font-medium">Category</p>
<p className="text-sm">{selectedSuggestion.crime_categories?.name || 'N/A'}</p> <p className="text-sm">{selectedSuggestion.crime_categories?.name || "N/A"}</p>
</div> </div>
<div className="bg-muted/50 rounded p-2"> <div className="bg-muted/50 rounded p-2">
<p className="text-xs text-muted-foreground font-medium">Status</p> <p className="text-xs text-muted-foreground font-medium">Status</p>
<p className="text-sm">{selectedSuggestion.status || 'N/A'}</p> <p className="text-sm">{selectedSuggestion.status || "N/A"}</p>
</div> </div>
</div> </div>
<div className="flex justify-between items-center pt-3 border-t border-border mt-3"> <div className="flex justify-between items-center pt-3 border-t border-border mt-3">
<Button <Button variant="outline" size="sm" onClick={handleCloseInfoBox}>
variant="outline" Close
size="sm" </Button>
onClick={handleCloseInfoBox} <Button
> variant="default"
Close size="sm"
</Button> onClick={handleFlyToIncident}
<Button disabled={!selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude}
variant="default" className="flex items-center gap-2"
size="sm" >
onClick={handleFlyToIncident} <span>Fly to Incident</span>
disabled={!selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude} <ExternalLink className="h-3.5 w-3.5" />
className="flex items-center gap-2" </Button>
> </div>
<span>Fly to Incident</span> </div>
<ExternalLink className="h-3.5 w-3.5" /> )}
</Button> </Card>
</div> )}
</div> </div>
)} </motion.div>
</Card> </>
)} )}
</div> </AnimatePresence>
</motion.div> </>
</> )
)}
</AnimatePresence>
</>
)
} }

View File

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

View File

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

View File

@ -2,7 +2,7 @@
import { useEffect, useCallback } from "react" import { useEffect, useCallback } from "react"
import type mapboxgl from "mapbox-gl" import mapboxgl from "mapbox-gl"
import type { GeoJSON } from "geojson" import type { GeoJSON } from "geojson"
import type { IClusterLayerProps } from "@/app/_utils/types/map" import type { IClusterLayerProps } from "@/app/_utils/types/map"
import { extractCrimeIncidents } from "@/app/_utils/map" import { extractCrimeIncidents } from "@/app/_utils/map"
@ -10,6 +10,7 @@ import { extractCrimeIncidents } from "@/app/_utils/map"
interface ExtendedClusterLayerProps extends IClusterLayerProps { interface ExtendedClusterLayerProps extends IClusterLayerProps {
clusteringEnabled?: boolean clusteringEnabled?: boolean
showClusters?: boolean showClusters?: boolean
sourceType?: string
} }
export default function ClusterLayer({ export default function ClusterLayer({
@ -20,6 +21,7 @@ export default function ClusterLayer({
focusedDistrictId, focusedDistrictId,
clusteringEnabled = false, clusteringEnabled = false,
showClusters = false, showClusters = false,
sourceType = "cbt",
}: ExtendedClusterLayerProps) { }: ExtendedClusterLayerProps) {
const handleClusterClick = useCallback( const handleClusterClick = useCallback(
(e: any) => { (e: any) => {
@ -35,7 +37,6 @@ export default function ClusterLayer({
const clusterId: number = features[0].properties?.cluster_id as number const clusterId: number = features[0].properties?.cluster_id as number
try { try {
// Get the expanded zoom level for this cluster
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom( ; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(
clusterId, clusterId,
(err, zoom) => { (err, zoom) => {
@ -46,7 +47,6 @@ export default function ClusterLayer({
const coordinates = (features[0].geometry as any).coordinates const coordinates = (features[0].geometry as any).coordinates
// Explicitly fly to the cluster location
map.flyTo({ map.flyTo({
center: coordinates, center: coordinates,
zoom: zoom ?? 12, zoom: zoom ?? 12,
@ -80,13 +80,38 @@ export default function ClusterLayer({
} }
if (!map.getSource("crime-incidents")) { 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", { map.addSource("crime-incidents", {
type: "geojson", type: "geojson",
data: { data: {
type: "FeatureCollection", type: "FeatureCollection",
features: allIncidents as GeoJSON.Feature[], features: features,
}, },
cluster: clusteringEnabled, cluster: clusteringEnabled,
clusterMaxZoom: 14, 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.on("mouseenter", "clusters", () => {
map.getCanvas().style.cursor = "pointer" map.getCanvas().style.cursor = "pointer"
}) })
@ -139,36 +261,66 @@ export default function ClusterLayer({
map.getCanvas().style.cursor = "" map.getCanvas().style.cursor = ""
}) })
// Remove and re-add click handler to avoid duplicates
map.off("click", "clusters", handleClusterClick) map.off("click", "clusters", handleClusterClick)
map.on("click", "clusters", handleClusterClick) map.on("click", "clusters", handleClusterClick)
} else { } else {
// Update source clustering option
try { try {
// We need to recreate the source if we're changing the clustering option
const currentSource = map.getSource("crime-incidents") as mapboxgl.GeoJSONSource 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 const existingClusterState = (currentSource as any).options?.cluster
if (existingClusterState !== clusteringEnabled) { const sourceTypeChanged =
// Remove existing layers that use this source 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("clusters")) map.removeLayer("clusters")
if (map.getLayer("cluster-count")) map.removeLayer("cluster-count") if (map.getLayer("cluster-count")) map.removeLayer("cluster-count")
if (map.getLayer("unclustered-point")) map.removeLayer("unclustered-point") 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") 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", { map.addSource("crime-incidents", {
type: "geojson", type: "geojson",
data: data, data: {
type: "FeatureCollection",
features: features,
},
cluster: clusteringEnabled, cluster: clusteringEnabled,
clusterMaxZoom: 14, clusterMaxZoom: 14,
clusterRadius: 50, clusterRadius: 50,
}) })
// Re-add the layers
if (!map.getLayer("clusters")) { if (!map.getLayer("clusters")) {
map.addLayer( 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) { } catch (error) {
console.error("Error updating cluster source:", error) console.error("Error updating cluster source:", error)
} }
// Update visibility based on focused district and showClusters flag
if (map.getLayer("clusters")) { if (map.getLayer("clusters")) {
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none") 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.off("click", "clusters", handleClusterClick)
map.on("click", "clusters", handleClusterClick) map.on("click", "clusters", handleClusterClick)
} }
@ -242,36 +502,150 @@ export default function ClusterLayer({
return () => { return () => {
if (map) { if (map) {
map.off("click", "clusters", handleClusterClick) 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(() => { useEffect(() => {
if (!map || !map.getSource("crime-incidents")) return if (!map || !map.getSource("crime-incidents")) return
try { try {
const allIncidents = extractCrimeIncidents(crimes, filterCategory) let features: GeoJSON.Feature[]
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
type: "FeatureCollection", if (sourceType === "cbu") {
features: allIncidents as GeoJSON.Feature[], 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) { } catch (error) {
console.error("Error updating incident data:", error) console.error("Error updating incident data:", error)
} }
}, [map, crimes, filterCategory]) }, [map, crimes, filterCategory, sourceType])
// Update visibility when showClusters changes
useEffect(() => { useEffect(() => {
if (!map || !map.getLayer("clusters") || !map.getLayer("cluster-count")) return if (!map) return
try { try {
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none") if (map.getLayer("clusters")) {
map.setLayoutProperty("cluster-count", "visibility", showClusters && !focusedDistrictId ? "visible" : "none") 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) { } catch (error) {
console.error("Error updating cluster visibility:", error) console.error("Error updating cluster visibility:", error)
} }
}, [map, showClusters, focusedDistrictId]) }, [map, showClusters, focusedDistrictId, sourceType])
return null return null
} }

View File

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

View File

@ -1171,3 +1171,4 @@ export function generateId(
return result.trim(); 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; source: string;
description: string; description: string;
verified: boolean; verified: boolean;
severity: 'high' | 'medium' | 'low'; severity: "Low" | "Medium" | "High" | "Unknown";
timestamp: Date; timestamp: Date;
created_at: Date; created_at: Date;
updated_at: Date; updated_at: Date;

View File

@ -1,33 +1,157 @@
export const districtCenters = [ export const districtCenters = [
{ kecamatan: "Sumbersari", lat: -8.170662, lng: 113.727582 }, {
{ kecamatan: "Wuluhan", lat: -8.365478, lng: 113.537137 }, kecamatan: "Ajung",
{ kecamatan: "Bangsalsari", lat: -8.2013, lng: 113.5323 }, lat: -8.243799,
{ kecamatan: "Kaliwates", lat: -8.1725, lng: 113.7000 }, lng: 113.642826,
{ 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: "Ambulu",
{ kecamatan: "Sumberbaru", lat: -8.119229, lng: 113.392973 }, lat: -8.3456,
{ kecamatan: "Patrang", lat: -8.14611, lng: 113.71250 }, lng: 113.6106,
{ 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: "Arjasa",
{ kecamatan: "Rambipuji", lat: -8.210581, lng: 113.603610 }, lat: -8.1042,
{ kecamatan: "Ajung", lat: -8.245360, lng: 113.653218 }, lng: 113.7398,
{ 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: "Balung",
{ kecamatan: "Umbulsari", lat: -8.2500, lng: 113.5000 }, lat: -8.2592,
{ kecamatan: "Kencong", lat: -8.3500, lng: 113.4000 }, lng: 113.5464,
{ 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: "Bangsalsari",
{ kecamatan: "Sumberjambe", lat: -8.1000, lng: 113.8000 }, lat: -8.1721,
{ kecamatan: "Sukowono", lat: -8.2000, lng: 113.7000 }, lng: 113.5312,
{ 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: "Gumukmas",
{ kecamatan: "Pakusari", lat: -8.2000, lng: 113.7000 }, lat: -8.3117,
{ kecamatan: "Arjasa", lat: -8.1500, lng: 113.7000 }, lng: 113.4297,
{ kecamatan: "Sukorambi", lat: -8.2000, lng: 113.6000 }, },
{ kecamatan: "Jelbuk", lat: -8.1000, lng: 113.7000 }, {
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 // Daftar semua seeders di sini
this.seeders = [ this.seeders = [
new RoleSeeder(prisma), // new RoleSeeder(prisma),
new ResourceSeeder(prisma), // new ResourceSeeder(prisma),
new PermissionSeeder(prisma), // new PermissionSeeder(prisma),
new CrimeCategoriesSeeder(prisma), // new CrimeCategoriesSeeder(prisma),
new GeoJSONSeeder(prisma), // new GeoJSONSeeder(prisma),
new UnitSeeder(prisma), // new UnitSeeder(prisma),
new DemographicsSeeder(prisma), // new DemographicsSeeder(prisma),
new CrimesSeeder(prisma), new CrimesSeeder(prisma),
// new CrimeIncidentsByUnitSeeder(prisma), // new CrimeIncidentsByUnitSeeder(prisma),
new CrimeIncidentsByTypeSeeder(prisma), new CrimeIncidentsByTypeSeeder(prisma),

View File

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

View File

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