feat: Enhance Crime Sidebar and Incident Tab with Recent Incidents
- Updated CrimeSidebar to accept recent incidents as a prop for user reports from the last 24 hours. - Modified SidebarIncidentsTab to format and display recent incidents, including filtering by category and sorting by timestamp. - Adjusted CrimeMap to manage the selected year based on the source type and to handle recent incidents data. - Renamed MapLegend to ClusterLegend for clarity and updated its props accordingly.
This commit is contained in:
parent
c3eeb4051e
commit
2ab60befd8
|
@ -1,75 +1,288 @@
|
|||
import React from 'react'
|
||||
import { AlertTriangle, MapPin, Calendar } from 'lucide-react'
|
||||
import { Info, Clock, MapPin, AlertTriangle, ChevronRight, AlertCircle, Shield } from 'lucide-react'
|
||||
import { Card, CardContent, CardHeader, CardFooter } from "@/app/_components/ui/card"
|
||||
import { Badge } from "@/app/_components/ui/badge"
|
||||
import { Card, CardContent } from "@/app/_components/ui/card"
|
||||
import { cn } from "@/app/_lib/utils"
|
||||
|
||||
interface IIncidentCardProps {
|
||||
title: string
|
||||
time: string
|
||||
location: string
|
||||
severity: "Low" | "Medium" | "High" | "Critical"
|
||||
time: string
|
||||
severity?: number | "Low" | "Medium" | "High" | "Critical"
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
showTimeAgo?: boolean
|
||||
status?: string | true
|
||||
isUserReport?: boolean
|
||||
}
|
||||
|
||||
export function IncidentCard({
|
||||
title,
|
||||
time,
|
||||
location,
|
||||
severity,
|
||||
time,
|
||||
severity = 1,
|
||||
onClick,
|
||||
showTimeAgo = true
|
||||
className,
|
||||
showTimeAgo = true,
|
||||
status,
|
||||
isUserReport = false
|
||||
}: IIncidentCardProps) {
|
||||
const getBadgeColor = () => {
|
||||
switch (severity) {
|
||||
case "Low": return "bg-green-500/20 text-green-300";
|
||||
case "Medium": return "bg-yellow-500/20 text-yellow-300";
|
||||
case "High": return "bg-orange-500/20 text-orange-300";
|
||||
case "Critical": return "bg-red-500/20 text-red-300";
|
||||
default: return "bg-gray-500/20 text-gray-300";
|
||||
// Helper to normalize severity to a number
|
||||
const normalizeSeverity = (sev: number | "Low" | "Medium" | "High" | "Critical"): number => {
|
||||
if (typeof sev === "number") return sev;
|
||||
switch (sev) {
|
||||
case "Critical":
|
||||
return 4;
|
||||
case "High":
|
||||
return 3;
|
||||
case "Medium":
|
||||
return 2;
|
||||
case "Low":
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
|
||||
const getBorderColor = () => {
|
||||
const getSeverityColor = (severity: number) => {
|
||||
switch (severity) {
|
||||
case "Low": return "border-l-green-500";
|
||||
case "Medium": return "border-l-yellow-500";
|
||||
case "High": return "border-l-orange-500";
|
||||
case "Critical": return "border-l-red-500";
|
||||
default: return "border-l-gray-500";
|
||||
case 4:
|
||||
return "border-purple-600 bg-purple-50 dark:bg-purple-950/30"
|
||||
case 3:
|
||||
return "border-red-500 bg-red-50 dark:bg-red-950/30"
|
||||
case 2:
|
||||
return "border-yellow-400 bg-yellow-50 dark:bg-yellow-950/30"
|
||||
case 1:
|
||||
default:
|
||||
return "border-blue-500 bg-blue-50 dark:bg-blue-950/30"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getSeverityText = (severity: number) => {
|
||||
switch (severity) {
|
||||
case 4:
|
||||
return "text-purple-600 dark:text-purple-400"
|
||||
case 3:
|
||||
return "text-red-500 dark:text-red-400"
|
||||
case 2:
|
||||
return "text-yellow-600 dark:text-yellow-400"
|
||||
case 1:
|
||||
default:
|
||||
return "text-blue-500 dark:text-blue-400"
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityLabel = (severity: number) => {
|
||||
switch (severity) {
|
||||
case 4:
|
||||
return "Critical"
|
||||
case 3:
|
||||
return "High"
|
||||
case 2:
|
||||
return "Medium"
|
||||
case 1:
|
||||
default:
|
||||
return "Low"
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case "resolved":
|
||||
return "bg-green-100 text-green-700 border-green-300 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800"
|
||||
case "pending":
|
||||
default:
|
||||
return "bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800"
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSeverity = normalizeSeverity(severity);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`bg-white/5 hover:bg-white/10 border-0 text-white shadow-none transition-colors border-l-2 ${getBorderColor()} ${onClick ? 'cursor-pointer' : ''}`}
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200 border-l-4",
|
||||
getSeverityColor(normalizedSeverity),
|
||||
"hover:shadow-md hover:translate-y-[-2px]",
|
||||
"group cursor-pointer",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="p-3 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium">{title}</p>
|
||||
<Badge className={`${getBadgeColor()} text-[9px] h-4 ml-1`}>{severity}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1.5 text-white/60">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{location}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 text-white/60 flex items-center gap-1">
|
||||
{showTimeAgo ? (
|
||||
time
|
||||
) : (
|
||||
<>
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{time}</span>
|
||||
</>
|
||||
<CardContent className="p-4 relative">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge className={`${getSeverityText(normalizedSeverity)} bg-white dark:bg-transparent border`}>
|
||||
{getSeverityLabel(normalizedSeverity)}
|
||||
</Badge>
|
||||
{isUserReport && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
User Report
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-base mb-2 line-clamp-1">{title}</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-y-2 gap-x-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-4 w-4 shrink-0" />
|
||||
<span>{time}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate max-w-[180px]">{location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add status badges for user reports */}
|
||||
{isUserReport && typeof status === "string" && (
|
||||
<Badge variant="outline" className={`mt-3 ${getStatusColor(status)}`}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function IncidentCardV2({
|
||||
title,
|
||||
location,
|
||||
time,
|
||||
severity = 1,
|
||||
onClick,
|
||||
className,
|
||||
showTimeAgo = true,
|
||||
status,
|
||||
isUserReport = false,
|
||||
}: IIncidentCardProps) {
|
||||
// Helper to normalize severity to a number
|
||||
const normalizeSeverity = (sev: number | "Low" | "Medium" | "High" | "Critical"): number => {
|
||||
if (typeof sev === "number") return sev
|
||||
switch (sev) {
|
||||
case "Critical":
|
||||
return 4
|
||||
case "High":
|
||||
return 3
|
||||
case "Medium":
|
||||
return 2
|
||||
case "Low":
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityGradient = (severity: number) => {
|
||||
switch (severity) {
|
||||
case 4:
|
||||
return "bg-gradient-to-r from-purple-500/10 to-purple-600/5 dark:from-purple-900/20 dark:to-purple-800/10"
|
||||
case 3:
|
||||
return "bg-gradient-to-r from-red-500/10 to-red-600/5 dark:from-red-900/20 dark:to-red-800/10"
|
||||
case 2:
|
||||
return "bg-gradient-to-r from-yellow-500/10 to-yellow-600/5 dark:from-yellow-900/20 dark:to-yellow-800/10"
|
||||
case 1:
|
||||
default:
|
||||
return "bg-gradient-to-r from-blue-500/10 to-blue-600/5 dark:from-blue-900/20 dark:to-blue-800/10"
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityIconColor = (severity: number) => {
|
||||
switch (severity) {
|
||||
case 4:
|
||||
return "text-purple-600 dark:text-purple-400"
|
||||
case 3:
|
||||
return "text-red-500 dark:text-red-400"
|
||||
case 2:
|
||||
return "text-yellow-600 dark:text-yellow-400"
|
||||
case 1:
|
||||
default:
|
||||
return "text-blue-500 dark:text-blue-400"
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityIcon = (severity: number) => {
|
||||
switch (severity) {
|
||||
case 4:
|
||||
case 3:
|
||||
return <AlertCircle className={`h-6 w-6 ${getSeverityIconColor(severity)}`} />
|
||||
case 2:
|
||||
case 1:
|
||||
default:
|
||||
return <Shield className={`h-6 w-6 ${getSeverityIconColor(severity)}`} />
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case "resolved":
|
||||
return "bg-green-100 text-green-700 border-green-300 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800"
|
||||
case "pending":
|
||||
default:
|
||||
return "bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800"
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSeverity = normalizeSeverity(severity)
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-300 border",
|
||||
getSeverityGradient(normalizedSeverity),
|
||||
"hover:shadow-md hover:translate-y-[-2px]",
|
||||
"group cursor-pointer",
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-stretch">
|
||||
{/* <div className="p-4 flex items-center justify-center">{getSeverityIcon(normalizedSeverity)}</div> */}
|
||||
|
||||
<div className="flex-1 p-4 pr-10 relative">
|
||||
<h3 className="font-medium text-base mb-2 line-clamp-1">{title}</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-4 w-4 shrink-0" />
|
||||
<span>{time}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate max-w-[180px]">{location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status badges */}
|
||||
<div className="mt-3 flex gap-2">
|
||||
{isUserReport && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
User Report
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{isUserReport && typeof status === "string" && (
|
||||
<Badge variant="outline" className={`${getStatusColor(status)}`}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ import {
|
|||
import { Button } from "@/app/_components/ui/button";
|
||||
import { Skeleton } from "@/app/_components/ui/skeleton";
|
||||
import { useMap } from "react-map-gl/mapbox";
|
||||
import { ICrimes } from "@/app/_utils/types/crimes";
|
||||
import { ICrimes, IIncidentLogs } from "@/app/_utils/types/crimes";
|
||||
|
||||
// Import sidebar components
|
||||
import { SidebarIncidentsTab } from "./tabs/incidents-tab";
|
||||
|
@ -43,6 +43,7 @@ interface CrimeSidebarProps {
|
|||
selectedYear: number | "all";
|
||||
selectedMonth?: number | "all";
|
||||
crimes: ICrimes[];
|
||||
recentIncidents?: IIncidentLogs[]; // User reports from last 24 hours
|
||||
isLoading?: boolean;
|
||||
sourceType?: string;
|
||||
}
|
||||
|
@ -54,6 +55,7 @@ export default function CrimeSidebar({
|
|||
selectedYear,
|
||||
selectedMonth,
|
||||
crimes = [],
|
||||
recentIncidents = [], // User reports from last 24 hours
|
||||
isLoading = false,
|
||||
sourceType = "cbt",
|
||||
}: CrimeSidebarProps) {
|
||||
|
@ -244,7 +246,7 @@ export default function CrimeSidebar({
|
|||
activeIncidentTab={activeIncidentTab}
|
||||
setActiveIncidentTab={setActiveIncidentTab}
|
||||
sourceType={sourceType}
|
||||
// setActiveTab={setActiveTab} // Pass setActiveTab function
|
||||
recentIncidents={recentIncidents} // Pass the recentIncidents
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
@ -1,22 +1,45 @@
|
|||
import React from 'react'
|
||||
import { AlertTriangle, AlertCircle, Clock, Shield, MapPin, ChevronLeft, ChevronRight, FileText, Calendar, ArrowRight, RefreshCw } from 'lucide-react'
|
||||
import { Card, CardContent } from "@/app/_components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
|
||||
import { Badge } from "@/app/_components/ui/badge"
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { formatMonthKey, getIncidentSeverity, getMonthName, getTimeAgo } from "@/app/_utils/common"
|
||||
import { SystemStatusCard } from "../components/system-status-card"
|
||||
import { IncidentCard } from "../components/incident-card"
|
||||
import { ICrimeAnalytics } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics'
|
||||
import React from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileText,
|
||||
MapPin,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/app/_components/ui/card";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/app/_components/ui/tabs";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import {
|
||||
formatMonthKey,
|
||||
getIncidentSeverity,
|
||||
getMonthName,
|
||||
getTimeAgo,
|
||||
} from "@/app/_utils/common";
|
||||
import { SystemStatusCard } from "../components/system-status-card";
|
||||
import { IncidentCard, IncidentCardV2 } from "../components/incident-card";
|
||||
import { ICrimeAnalytics } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics";
|
||||
import { IIncidentLogs } from "@/app/_utils/types/crimes";
|
||||
|
||||
interface Incident {
|
||||
id: string;
|
||||
category: string;
|
||||
address: string;
|
||||
timestamp: string;
|
||||
timestamp: string | Date; // Accept both string and Date for timestamp
|
||||
district?: string;
|
||||
severity?: number;
|
||||
status?: string;
|
||||
severity?: number | "Low" | "Medium" | "High" | "Critical"; // Match severity types
|
||||
status?: string | true; // Match status types
|
||||
description?: string;
|
||||
location?: {
|
||||
lat: number;
|
||||
|
@ -34,16 +57,11 @@ interface SidebarIncidentsTabProps {
|
|||
selectedCategory: string | "all";
|
||||
getTimePeriodDisplay: () => string;
|
||||
paginationState: Record<string, number>;
|
||||
handlePageChange: (monthKey: string, direction: 'next' | 'prev') => void;
|
||||
handlePageChange: (monthKey: string, direction: "next" | "prev") => void;
|
||||
handleIncidentClick: (incident: Incident) => void;
|
||||
activeIncidentTab: string;
|
||||
setActiveIncidentTab: (tab: string) => void;
|
||||
}
|
||||
|
||||
interface CrimeCategory {
|
||||
type: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
recentIncidents?: IIncidentLogs[]; // User reports from last 24 hours
|
||||
}
|
||||
|
||||
export function SidebarIncidentsTab({
|
||||
|
@ -60,16 +78,58 @@ export function SidebarIncidentsTab({
|
|||
handleIncidentClick,
|
||||
activeIncidentTab,
|
||||
setActiveIncidentTab,
|
||||
sourceType = "cbt"
|
||||
sourceType = "cbt",
|
||||
recentIncidents = [],
|
||||
}: SidebarIncidentsTabProps & { sourceType?: string }) {
|
||||
const topCategories = crimeStats.categoryCounts ?
|
||||
Object.entries(crimeStats.categoryCounts)
|
||||
const currentYear = new Date().getFullYear();
|
||||
const isCurrentYear = selectedYear === currentYear ||
|
||||
selectedYear === "all";
|
||||
|
||||
const topCategories = crimeStats.categoryCounts
|
||||
? Object.entries(crimeStats.categoryCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4)
|
||||
.map(([type, count]) => {
|
||||
const percentage = Math.round((count / crimeStats.totalIncidents) * 100) || 0
|
||||
return { type, count, percentage }
|
||||
}) : []
|
||||
const percentage =
|
||||
Math.round((count / crimeStats.totalIncidents) * 100) || 0;
|
||||
return { type, count, percentage };
|
||||
})
|
||||
: [];
|
||||
|
||||
// Filter history incidents by the selected year
|
||||
const filteredAvailableMonths = crimeStats.availableMonths.filter(
|
||||
(monthKey) => {
|
||||
// If selectedYear is "all", show all months
|
||||
if (selectedYear === "all") return true;
|
||||
|
||||
// Extract year from the monthKey (format: YYYY-MM)
|
||||
const yearFromKey = parseInt(monthKey.split("-")[0]);
|
||||
return yearFromKey === selectedYear;
|
||||
},
|
||||
);
|
||||
|
||||
// Format recent incidents data from user reports
|
||||
const formattedRecentIncidents = recentIncidents
|
||||
.filter((incident) => {
|
||||
// Filter by category if needed
|
||||
return selectedCategory === "all" ||
|
||||
incident.category?.toLowerCase() ===
|
||||
selectedCategory.toLowerCase();
|
||||
})
|
||||
.map((incident) => ({
|
||||
id: incident.id,
|
||||
category: incident.category || "Uncategorized",
|
||||
address: incident.address || "Unknown location",
|
||||
timestamp: incident.timestamp
|
||||
? incident.timestamp.toString()
|
||||
: new Date().toISOString(), // Convert to string
|
||||
description: incident.description || "",
|
||||
status: incident.verified || "pending",
|
||||
severity: getIncidentSeverity(incident),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||
);
|
||||
|
||||
// If source type is CBU, display warning instead of regular content
|
||||
if (sourceType === "cbu") {
|
||||
|
@ -80,26 +140,39 @@ export function SidebarIncidentsTab({
|
|||
<AlertTriangle className="h-8 w-8 text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-medium text-white mb-2">Limited Data View</h3>
|
||||
<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.
|
||||
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>
|
||||
<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>
|
||||
<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.
|
||||
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">
|
||||
|
@ -115,7 +188,9 @@ export function SidebarIncidentsTab({
|
|||
|
||||
<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.
|
||||
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>
|
||||
|
@ -128,8 +203,10 @@ export function SidebarIncidentsTab({
|
|||
{/* Enhanced info card */}
|
||||
<Card className="bg-gradient-to-r from-sidebar-primary/30 to-sidebar-primary/20 border border-sidebar-primary/20 overflow-hidden">
|
||||
<CardContent className="p-4 text-sm relative">
|
||||
<div className="absolute top-0 right-0 w-24 h-24 bg-sidebar-primary/10 rounded-full -translate-y-1/2 translate-x-1/2"></div>
|
||||
<div className="absolute bottom-0 left-0 w-16 h-16 bg-sidebar-primary/10 rounded-full translate-y-1/2 -translate-x-1/2"></div>
|
||||
<div className="absolute top-0 right-0 w-24 h-24 bg-sidebar-primary/10 rounded-full -translate-y-1/2 translate-x-1/2">
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 w-16 h-16 bg-sidebar-primary/10 rounded-full translate-y-1/2 -translate-x-1/2">
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
@ -143,13 +220,19 @@ export function SidebarIncidentsTab({
|
|||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MapPin className="h-4 w-4 text-sidebar-primary" />
|
||||
<span className="text-sidebar-foreground/70">{location}</span>
|
||||
<span className="text-sidebar-foreground/70">
|
||||
{location}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-sidebar-accent/30 p-2 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-emerald-400" />
|
||||
<span>
|
||||
<strong>{crimeStats.totalIncidents || 0}</strong> incidents reported
|
||||
{selectedMonth !== 'all' ? ` in ${getMonthName(Number(selectedMonth))}` : ` in ${selectedYear}`}
|
||||
<strong>{crimeStats.totalIncidents || 0}</strong>
|
||||
{" "}
|
||||
incidents reported
|
||||
{selectedMonth !== "all"
|
||||
? ` in ${getMonthName(Number(selectedMonth))}`
|
||||
: ` in ${selectedYear}`}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
@ -160,7 +243,9 @@ export function SidebarIncidentsTab({
|
|||
<SystemStatusCard
|
||||
title="Total Cases"
|
||||
status={`${crimeStats?.totalIncidents || 0}`}
|
||||
statusIcon={<AlertCircle className="h-4 w-4 text-green-400" />}
|
||||
statusIcon={
|
||||
<AlertCircle className="h-4 w-4 text-green-400" />
|
||||
}
|
||||
statusColor="text-green-400"
|
||||
updatedTime={getTimePeriodDisplay()}
|
||||
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
||||
|
@ -177,7 +262,9 @@ export function SidebarIncidentsTab({
|
|||
/>
|
||||
<SystemStatusCard
|
||||
title="Top Category"
|
||||
status={topCategories.length > 0 ? topCategories[0].type : "None"}
|
||||
status={topCategories.length > 0
|
||||
? topCategories[0].type
|
||||
: "None"}
|
||||
statusIcon={<Shield className="h-4 w-4 text-green-400" />}
|
||||
statusColor="text-green-400"
|
||||
bgColor="bg-gradient-to-br from-sidebar-accent/30 to-sidebar-accent/20"
|
||||
|
@ -224,136 +311,274 @@ export function SidebarIncidentsTab({
|
|||
|
||||
{/* Recent Incidents Tab Content */}
|
||||
<TabsContent value="recent" className="m-0 p-0">
|
||||
{crimeStats.recentIncidents.length === 0 ? (
|
||||
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<AlertCircle className="h-6 w-6 text-white/40" />
|
||||
<p className="text-sm text-white/70">
|
||||
{selectedCategory !== "all"
|
||||
? `No ${selectedCategory} incidents found`
|
||||
: "No recent incidents reported"}
|
||||
{!isCurrentYear
|
||||
? (
|
||||
<Card className="bg-amber-900/20 border border-amber-500/30 mb-3">
|
||||
<CardContent className="p-4 flex flex-col items-center text-center">
|
||||
<div className="bg-amber-500/20 rounded-full p-2 mb-2">
|
||||
<Calendar className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-amber-200">
|
||||
Year Selection Notice
|
||||
</h4>
|
||||
<p className="text-xs text-amber-100/80 mt-1 mb-2">
|
||||
Recent incidents are only available for
|
||||
the current year ({currentYear}).
|
||||
</p>
|
||||
<p className="text-xs text-white/50">Try adjusting your filters or checking back later</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs border-amber-500/50 bg-amber-500/10 hover:bg-amber-500/20 text-amber-300"
|
||||
onClick={() =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("set-year", {
|
||||
detail: currentYear,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||
Switch to {currentYear}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
: formattedRecentIncidents.length === 0
|
||||
? (
|
||||
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<AlertCircle className="h-6 w-6 text-white/40" />
|
||||
<p className="text-sm text-white/70">
|
||||
{selectedCategory !== "all"
|
||||
? `No ${selectedCategory} incidents reported in the last 24 hours`
|
||||
: "No incidents reported in the last 24 hours"}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">
|
||||
The most recent user reports will
|
||||
appear here
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
: (
|
||||
<div className="space-y-3">
|
||||
{formattedRecentIncidents.slice(0, 6).map((
|
||||
incident,
|
||||
) => (
|
||||
<IncidentCardV2
|
||||
key={incident.id}
|
||||
title={`${incident.category || "Unknown"
|
||||
} in ${incident.address?.split(",")[0] ||
|
||||
"Unknown Location"
|
||||
}`}
|
||||
time={typeof incident.timestamp ===
|
||||
"string"
|
||||
? getTimeAgo(incident.timestamp)
|
||||
: getTimeAgo(incident.timestamp)}
|
||||
location={incident.address?.split(",")
|
||||
.slice(1, 3).join(", ") ||
|
||||
"Unknown Location"}
|
||||
severity={incident.severity}
|
||||
onClick={() =>
|
||||
handleIncidentClick(
|
||||
incident as Incident,
|
||||
)}
|
||||
status={incident.status}
|
||||
isUserReport={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{crimeStats.recentIncidents.slice(0, 6).map((incident: Incident) => (
|
||||
<IncidentCard
|
||||
key={incident.id}
|
||||
title={`${incident.category || 'Unknown'} in ${incident.address?.split(',')[0] || 'Unknown Location'}`}
|
||||
time={incident.timestamp ? getTimeAgo(incident.timestamp) : 'Unknown time'}
|
||||
location={incident.address?.split(',').slice(1, 3).join(', ') || 'Unknown Location'}
|
||||
severity={getIncidentSeverity(incident)}
|
||||
onClick={() => handleIncidentClick(incident)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* History Incidents Tab Content */}
|
||||
<TabsContent value="history" className="m-0 p-0">
|
||||
{crimeStats.availableMonths && crimeStats.availableMonths.length === 0 ? (
|
||||
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileText className="h-6 w-6 text-white/40" />
|
||||
<p className="text-sm text-white/70">
|
||||
{selectedCategory !== "all"
|
||||
? `No ${selectedCategory} incidents found in the selected period`
|
||||
: "No incidents found in the selected period"}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">Try adjusting your filters</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs text-white/60">
|
||||
Showing incidents from {crimeStats.availableMonths.length} {crimeStats.availableMonths.length === 1 ? 'month' : 'months'}
|
||||
</span>
|
||||
<Badge variant="outline" className="h-5 text-[10px]">
|
||||
{selectedCategory !== "all" ? selectedCategory : "All Categories"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{crimeStats.availableMonths.map((monthKey: string) => {
|
||||
const incidents = crimeStats.incidentsByMonthDetail[monthKey] || []
|
||||
const pageSize = 5
|
||||
const currentPage = paginationState[monthKey] || 0
|
||||
const totalPages = Math.ceil(incidents.length / pageSize)
|
||||
const startIdx = currentPage * pageSize
|
||||
const endIdx = startIdx + pageSize
|
||||
const paginatedIncidents = incidents.slice(startIdx, endIdx)
|
||||
|
||||
if (incidents.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={monthKey} className="mb-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<h4 className="font-medium text-xs">{formatMonthKey(monthKey)}</h4>
|
||||
</div>
|
||||
<Badge variant="secondary" className="h-5 text-[10px]">
|
||||
{incidents.length} incident{incidents.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{paginatedIncidents.map((incident: Incident) => (
|
||||
<IncidentCard
|
||||
key={incident.id}
|
||||
title={`${incident.category || 'Unknown'} in ${incident.address?.split(',')[0] || 'Unknown Location'}`}
|
||||
time={incident.timestamp ? new Date(incident.timestamp).toLocaleDateString() : 'Unknown date'}
|
||||
location={incident.address?.split(',').slice(1, 3).join(', ') || 'Unknown Location'}
|
||||
severity={getIncidentSeverity(incident)}
|
||||
onClick={() => handleIncidentClick(incident)}
|
||||
showTimeAgo={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-white/50">
|
||||
Page {currentPage + 1} of {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 py-1 text-[10px]"
|
||||
disabled={currentPage === 0}
|
||||
onClick={() => handlePageChange(monthKey, 'prev')}
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||
Prev
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 py-1 text-[10px]"
|
||||
disabled={currentPage >= totalPages - 1}
|
||||
onClick={() => handlePageChange(monthKey, 'next')}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{filteredAvailableMonths.length === 0
|
||||
? (
|
||||
<Card className="bg-white/5 border-0 text-white shadow-none">
|
||||
<CardContent className="p-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileText className="h-6 w-6 text-white/40" />
|
||||
<p className="text-sm text-white/70">
|
||||
{selectedCategory !== "all"
|
||||
? `No ${selectedCategory} incidents found in ${selectedYear === "all"
|
||||
? "any period"
|
||||
: selectedYear
|
||||
}`
|
||||
: `No incidents found in ${selectedYear === "all"
|
||||
? "any period"
|
||||
: selectedYear
|
||||
}`}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">
|
||||
Try adjusting your filters
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
: (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-xs text-white/60">
|
||||
Showing incidents from{" "}
|
||||
{filteredAvailableMonths.length}{" "}
|
||||
{filteredAvailableMonths.length === 1
|
||||
? "month"
|
||||
: "months"}
|
||||
{selectedYear !== "all"
|
||||
? ` in ${selectedYear}`
|
||||
: ""}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 text-[10px]"
|
||||
>
|
||||
{selectedCategory !== "all"
|
||||
? selectedCategory
|
||||
: "All Categories"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{filteredAvailableMonths.map(
|
||||
(monthKey: string) => {
|
||||
const incidents = crimeStats
|
||||
.incidentsByMonthDetail[
|
||||
monthKey
|
||||
] || [];
|
||||
const pageSize = 5;
|
||||
const currentPage =
|
||||
paginationState[monthKey] || 0;
|
||||
const totalPages = Math.ceil(
|
||||
incidents.length / pageSize,
|
||||
);
|
||||
const startIdx = currentPage * pageSize;
|
||||
const endIdx = startIdx + pageSize;
|
||||
const paginatedIncidents = incidents
|
||||
.slice(startIdx, endIdx);
|
||||
|
||||
if (incidents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={monthKey}
|
||||
className="mb-5"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Calendar className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<h4 className="font-medium text-xs">
|
||||
{formatMonthKey(
|
||||
monthKey,
|
||||
)}
|
||||
</h4>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-5 text-[10px]"
|
||||
>
|
||||
{incidents.length}{" "}
|
||||
incident{incidents
|
||||
.length !== 1
|
||||
? "s"
|
||||
: ""}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{paginatedIncidents.map((
|
||||
incident: Incident,
|
||||
) => (
|
||||
<IncidentCardV2
|
||||
key={incident.id}
|
||||
title={`${incident
|
||||
.category ||
|
||||
"Unknown"
|
||||
} in ${incident.address
|
||||
?.split(
|
||||
",",
|
||||
)[0] ||
|
||||
"Unknown Location"
|
||||
}`}
|
||||
time={incident
|
||||
.timestamp
|
||||
? new Date(
|
||||
incident
|
||||
.timestamp,
|
||||
).toLocaleDateString()
|
||||
: "Unknown date"}
|
||||
location={incident
|
||||
.address?.split(
|
||||
",",
|
||||
).slice(1, 3)
|
||||
.join(", ") ||
|
||||
"Unknown Location"}
|
||||
severity={getIncidentSeverity(
|
||||
incident,
|
||||
)}
|
||||
onClick={() =>
|
||||
handleIncidentClick(
|
||||
incident,
|
||||
)}
|
||||
showTimeAgo={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-white/50">
|
||||
Page{" "}
|
||||
{currentPage + 1} of
|
||||
{" "}
|
||||
{totalPages}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 py-1 text-[10px]"
|
||||
disabled={currentPage ===
|
||||
0}
|
||||
onClick={() =>
|
||||
handlePageChange(
|
||||
monthKey,
|
||||
"prev",
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3 mr-1" />
|
||||
Prev
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 py-1 text-[10px]"
|
||||
disabled={currentPage >=
|
||||
totalPages -
|
||||
1}
|
||||
onClick={() =>
|
||||
handlePageChange(
|
||||
monthKey,
|
||||
"next",
|
||||
)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-3 w-3 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@ import { getMonthName } from "@/app/_utils/common";
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFullscreen } from "@/app/_hooks/use-fullscreen";
|
||||
import { Overlay } from "./overlay";
|
||||
import MapLegend from "./legends/map-legend";
|
||||
import clusterLegend from "./legends/map-legend";
|
||||
import UnitsLegend from "./legends/units-legend";
|
||||
import TimelineLegend from "./legends/timeline-legend";
|
||||
import {
|
||||
|
@ -27,16 +27,6 @@ import {
|
|||
import MapSelectors from "./controls/map-selector";
|
||||
|
||||
import { cn } from "@/app/_lib/utils";
|
||||
import {
|
||||
$Enums,
|
||||
crime_categories,
|
||||
crime_incidents,
|
||||
crimes,
|
||||
demographics,
|
||||
districts,
|
||||
geographics,
|
||||
locations,
|
||||
} from "@prisma/client";
|
||||
import { CrimeTimelapse } from "./controls/bottom/crime-timelapse";
|
||||
import { ITooltipsControl } from "./controls/top/tooltips";
|
||||
import CrimeSidebar from "./controls/left/sidebar/map-sidebar";
|
||||
|
@ -54,6 +44,7 @@ import {
|
|||
} from "@/app/_utils/mock/ews-data";
|
||||
import { useMap } from "react-map-gl/mapbox";
|
||||
import PanicButtonDemo from "./controls/panic-button-demo";
|
||||
import ClusterLegend from "./legends/map-legend";
|
||||
|
||||
export default function CrimeMap() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||
|
@ -64,7 +55,7 @@ export default function CrimeMap() {
|
|||
IDistrictFeature | null
|
||||
>(null);
|
||||
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu");
|
||||
const [selectedYear, setSelectedYear] = useState<number | "all">(2024);
|
||||
const [selectedYear, setSelectedYear] = useState<number | "all">();
|
||||
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | "all">(
|
||||
"all",
|
||||
|
@ -75,7 +66,6 @@ export default function CrimeMap() {
|
|||
const [useAllMonths, setUseAllMonths] = useState<boolean>(false);
|
||||
|
||||
const [showAllIncidents, setShowAllIncidents] = useState(false);
|
||||
const [showLegend, setShowLegend] = useState<boolean>(true);
|
||||
const [showUnitsLayer, setShowUnitsLayer] = useState(false);
|
||||
const [showClusters, setShowClusters] = useState(false);
|
||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||
|
@ -128,6 +118,12 @@ export default function CrimeMap() {
|
|||
|
||||
const { data: recentIncidents } = useGetRecentIncidents();
|
||||
|
||||
useEffect(() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const defaultYear = selectedSourceType === "cbu" ? 2024 : currentYear;
|
||||
setSelectedYear(defaultYear);
|
||||
}, [selectedSourceType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeControl === "heatmap" || activeControl === "timeline" ||
|
||||
|
@ -136,12 +132,13 @@ export default function CrimeMap() {
|
|||
setSelectedYear("all");
|
||||
setUseAllYears(true);
|
||||
setUseAllMonths(true);
|
||||
} else {
|
||||
setSelectedYear(2024);
|
||||
} else if (selectedYear === "all") {
|
||||
const currentYear = new Date().getFullYear();
|
||||
setSelectedYear(selectedSourceType === "cbu" ? 2024 : currentYear);
|
||||
setUseAllYears(false);
|
||||
setUseAllMonths(false);
|
||||
}
|
||||
}, [activeControl]);
|
||||
}, [activeControl, selectedSourceType, selectedYear]);
|
||||
|
||||
const crimesBySourceType = useMemo(() => {
|
||||
if (!crimes) return [];
|
||||
|
@ -218,6 +215,20 @@ export default function CrimeMap() {
|
|||
setEwsIncidents(getAllIncidents());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSetYear = (e: CustomEvent) => {
|
||||
if (typeof e.detail === 'number') {
|
||||
setSelectedYear(e.detail);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('set-year', handleSetYear as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('set-year', handleSetYear as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTriggerAlert = useCallback(
|
||||
(priority: "high" | "medium" | "low") => {
|
||||
const newIncident = addMockIncident({ priority });
|
||||
|
@ -243,6 +254,10 @@ export default function CrimeMap() {
|
|||
const handleSourceTypeChange = useCallback((sourceType: string) => {
|
||||
setSelectedSourceType(sourceType);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const defaultYear = sourceType === "cbu" ? 2024 : currentYear;
|
||||
setSelectedYear(defaultYear);
|
||||
|
||||
if (sourceType === "cbu") {
|
||||
setActiveControl("clusters");
|
||||
setShowClusters(true);
|
||||
|
@ -270,16 +285,17 @@ export default function CrimeMap() {
|
|||
}, []);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setSelectedYear(2024);
|
||||
const currentYear = new Date().getFullYear();
|
||||
const defaultYear = selectedSourceType === "cbu" ? 2024 : currentYear;
|
||||
setSelectedYear(defaultYear);
|
||||
setSelectedMonth("all");
|
||||
setSelectedCategory("all");
|
||||
}, []);
|
||||
}, [selectedSourceType]);
|
||||
|
||||
const getMapTitle = () => {
|
||||
if (useAllYears) {
|
||||
return `All Years Data ${
|
||||
selectedCategory !== "all" ? `- ${selectedCategory}` : ""
|
||||
}`;
|
||||
return `All Years Data ${selectedCategory !== "all" ? `- ${selectedCategory}` : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
let title = `${selectedYear}`;
|
||||
|
@ -357,13 +373,19 @@ export default function CrimeMap() {
|
|||
);
|
||||
}, [crimes, crimesBySourceType, filteredCrimes, selectedSourceType]);
|
||||
|
||||
const disableYearMonth = activeControl === "incidents" || activeControl === "heatmap" || activeControl === "timeline"
|
||||
|
||||
const activeIncidents = useMemo(() => {
|
||||
return ewsIncidents.filter((incident) => incident.status === "active");
|
||||
}, [ewsIncidents])
|
||||
|
||||
return (
|
||||
<Card className="w-full p-0 border-none shadow-none h-96">
|
||||
<CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between">
|
||||
<CardTitle>Crime Map {getMapTitle()}</CardTitle>
|
||||
<MapSelectors
|
||||
availableYears={availableYears || []}
|
||||
selectedYear={selectedYear}
|
||||
selectedYear={selectedYear ?? "all"}
|
||||
setSelectedYear={setSelectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
setSelectedMonth={setSelectedMonth}
|
||||
|
@ -385,150 +407,136 @@ export default function CrimeMap() {
|
|||
</div>
|
||||
)
|
||||
: crimesError
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||
<p className="text-center">
|
||||
Failed to load crime data. Please try again
|
||||
later.
|
||||
</p>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className="mapbox-container overlay-bg relative h-[600px]"
|
||||
ref={mapContainerRef}
|
||||
>
|
||||
? (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||
<p className="text-center">
|
||||
Failed to load crime data. Please try again
|
||||
later.
|
||||
</p>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out",
|
||||
!sidebarCollapsed && isFullscreen &&
|
||||
"ml-[400px]",
|
||||
)}
|
||||
className="mapbox-container overlay-bg relative h-[600px]"
|
||||
ref={mapContainerRef}
|
||||
>
|
||||
<div className="">
|
||||
<MapView
|
||||
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||
className="h-[600px] w-full rounded-md"
|
||||
>
|
||||
<Layers
|
||||
crimes={filteredCrimes || []}
|
||||
units={fetchedUnits || []}
|
||||
year={selectedYear.toString()}
|
||||
month={selectedMonth.toString()}
|
||||
filterCategory={selectedCategory}
|
||||
activeControl={activeControl}
|
||||
useAllData={useAllYears}
|
||||
showEWS={showEWS}
|
||||
recentIncidents={recentIncidents ||
|
||||
[]}
|
||||
sourceType={selectedSourceType}
|
||||
/>
|
||||
|
||||
{isFullscreen && (
|
||||
<>
|
||||
<div className="absolute flex w-full p-2">
|
||||
<Tooltips
|
||||
activeControl={activeControl}
|
||||
onControlChange={handleControlChange}
|
||||
selectedSourceType={selectedSourceType}
|
||||
setSelectedSourceType={handleSourceTypeChange}
|
||||
availableSourceTypes={availableSourceTypes ||
|
||||
[]}
|
||||
selectedYear={selectedYear}
|
||||
setSelectedYear={setSelectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
setSelectedMonth={setSelectedMonth}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
availableYears={availableYears ||
|
||||
[]}
|
||||
categories={categories}
|
||||
crimes={filteredCrimes}
|
||||
disableYearMonth={activeControl ===
|
||||
"incidents" ||
|
||||
activeControl ===
|
||||
"heatmap" ||
|
||||
activeControl ===
|
||||
"timeline"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mapboxMap && (
|
||||
<EWSAlertLayer
|
||||
map={mapboxMap}
|
||||
incidents={ewsIncidents}
|
||||
onIncidentResolved={handleResolveIncident}
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayPanicDemo && (
|
||||
<div className="absolute top-0 right-20 z-50 p-2">
|
||||
<PanicButtonDemo
|
||||
onTriggerAlert={handleTriggerAlert}
|
||||
onResolveAllAlerts={handleResolveAllAlerts}
|
||||
activeIncidents={ewsIncidents
|
||||
.filter((inc) =>
|
||||
inc.status ===
|
||||
"active"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CrimeSidebar
|
||||
crimes={filteredCrimes ||[]}
|
||||
defaultCollapsed={sidebarCollapsed}
|
||||
selectedCategory={selectedCategory}
|
||||
selectedYear={selectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
sourceType={selectedSourceType}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-20 right-0 z-20 p-2">
|
||||
{showClusters && (
|
||||
<MapLegend position="bottom-right" />
|
||||
)}
|
||||
|
||||
{!showClusters && (
|
||||
<MapLegend position="bottom-right" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showUnitsLayer && (
|
||||
<div className="absolute bottom-20 right-0 z-10 p-2">
|
||||
<UnitsLegend
|
||||
categories={categories}
|
||||
position="bottom-right"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTimelineLayer && (
|
||||
<div className="absolute flex bottom-20 right-0 z-10 p-2">
|
||||
<TimelineLegend position="bottom-right" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="absolute flex w-full bottom-0">
|
||||
<CrimeTimelapse
|
||||
startYear={2020}
|
||||
endYear={2024}
|
||||
autoPlay={false}
|
||||
onChange={handleTimelineChange}
|
||||
onPlayingChange={handleTimelinePlayingChange}
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-300 ease-in-out",
|
||||
!sidebarCollapsed && isFullscreen &&
|
||||
"ml-[400px]",
|
||||
)}
|
||||
>
|
||||
<div className="">
|
||||
<MapView
|
||||
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||
className="h-[600px] w-full rounded-md"
|
||||
>
|
||||
<Layers
|
||||
crimes={filteredCrimes || []}
|
||||
units={fetchedUnits || []}
|
||||
year={selectedYear?.toString() ?? "all"}
|
||||
month={selectedMonth.toString()}
|
||||
filterCategory={selectedCategory}
|
||||
activeControl={activeControl}
|
||||
useAllData={useAllYears}
|
||||
showEWS={showEWS}
|
||||
recentIncidents={recentIncidents ||
|
||||
[]}
|
||||
sourceType={selectedSourceType}
|
||||
/>
|
||||
</div>
|
||||
</MapView>
|
||||
|
||||
{isFullscreen && (
|
||||
<>
|
||||
<div className="absolute flex w-full p-2">
|
||||
<Tooltips
|
||||
activeControl={activeControl}
|
||||
onControlChange={handleControlChange}
|
||||
selectedSourceType={selectedSourceType}
|
||||
setSelectedSourceType={handleSourceTypeChange}
|
||||
availableSourceTypes={availableSourceTypes || []}
|
||||
selectedYear={selectedYear ?? "all"}
|
||||
setSelectedYear={setSelectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
setSelectedMonth={setSelectedMonth}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
availableYears={availableYears || []}
|
||||
categories={categories}
|
||||
crimes={filteredCrimes}
|
||||
disableYearMonth={disableYearMonth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mapboxMap && (
|
||||
<EWSAlertLayer
|
||||
map={mapboxMap}
|
||||
incidents={ewsIncidents}
|
||||
onIncidentResolved={handleResolveIncident}
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayPanicDemo && (
|
||||
<div className="absolute top-0 right-20 z-50 p-2">
|
||||
<PanicButtonDemo
|
||||
onTriggerAlert={handleTriggerAlert}
|
||||
onResolveAllAlerts={handleResolveAllAlerts}
|
||||
activeIncidents={activeIncidents}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CrimeSidebar
|
||||
crimes={filteredCrimes || []}
|
||||
recentIncidents={recentIncidents || []}
|
||||
defaultCollapsed={sidebarCollapsed}
|
||||
selectedCategory={selectedCategory}
|
||||
selectedYear={selectedYear ?? "all"}
|
||||
selectedMonth={selectedMonth}
|
||||
sourceType={selectedSourceType}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-20 right-0 z-20 p-2">
|
||||
{showClusters && (
|
||||
<ClusterLegend position="bottom-right" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showUnitsLayer && (
|
||||
<div className="absolute bottom-20 right-0 z-10 p-2">
|
||||
<UnitsLegend
|
||||
categories={categories}
|
||||
position="bottom-right"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showTimelineLayer && (
|
||||
<div className="absolute flex bottom-20 right-0 z-10 p-2">
|
||||
<TimelineLegend position="bottom-right" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="absolute flex w-full bottom-0">
|
||||
<CrimeTimelapse
|
||||
startYear={2020}
|
||||
endYear={2024}
|
||||
autoPlay={false}
|
||||
onChange={handleTimelineChange}
|
||||
onPlayingChange={handleTimelinePlayingChange}
|
||||
/>
|
||||
</div>
|
||||
</MapView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
@ -2,11 +2,11 @@ import { CRIME_RATE_COLORS } from "@/app/_utils/const/map";
|
|||
import { Overlay } from "../overlay";
|
||||
import { ControlPosition } from "mapbox-gl";
|
||||
|
||||
interface MapLegendProps {
|
||||
interface IClusterLegendProps {
|
||||
position?: ControlPosition
|
||||
}
|
||||
|
||||
export default function MapLegend({ position = "bottom-right" }: MapLegendProps) {
|
||||
export default function ClusterLegend({ position = "bottom-right" }: IClusterLegendProps) {
|
||||
return (
|
||||
// <Overlay position={position}>
|
||||
<div className="flex flex-row text-xs font-semibold font-sans text-background z-0">
|
||||
|
|
Loading…
Reference in New Issue