Refactor map utility functions and components
- Moved map utility functions from `map.ts` to `map/common.ts` for better organization. - Updated imports in `district-extrusion-layer.tsx`, `district-layer.tsx`, `layers.tsx`, and `distance-info.tsx` to reflect the new location of utility functions. - Enhanced `DistrictFillLineLayer` to improve handling of fill color expressions and opacity based on active controls. - Introduced new types for crime source in `map.ts` and updated relevant components to use these types. - Added a new `IncidentDetailTab` component to display detailed information about incidents, including severity and status. - Improved handling of severity and status information across components for consistency and clarity.
This commit is contained in:
parent
2ab60befd8
commit
223195e3fb
|
@ -3,6 +3,8 @@ import { Info, Clock, MapPin, AlertTriangle, ChevronRight, AlertCircle, Shield }
|
||||||
import { Card, CardContent, CardHeader, CardFooter } from "@/app/_components/ui/card"
|
import { Card, CardContent, CardHeader, CardFooter } from "@/app/_components/ui/card"
|
||||||
import { Badge } from "@/app/_components/ui/badge"
|
import { Badge } from "@/app/_components/ui/badge"
|
||||||
import { cn } from "@/app/_lib/utils"
|
import { cn } from "@/app/_lib/utils"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { getSeverityGradient, getStatusInfo, normalizeSeverity } from '@/app/_utils/map/common'
|
||||||
|
|
||||||
interface IIncidentCardProps {
|
interface IIncidentCardProps {
|
||||||
title: string
|
title: string
|
||||||
|
@ -14,144 +16,158 @@ interface IIncidentCardProps {
|
||||||
showTimeAgo?: boolean
|
showTimeAgo?: boolean
|
||||||
status?: string | true
|
status?: string | true
|
||||||
isUserReport?: boolean
|
isUserReport?: boolean
|
||||||
|
incidentId?: string // Added incident ID for navigation
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IncidentCard({
|
// export function IncidentCard({
|
||||||
title,
|
// title,
|
||||||
location,
|
// location,
|
||||||
time,
|
// time,
|
||||||
severity = 1,
|
// severity = 1,
|
||||||
onClick,
|
// onClick,
|
||||||
className,
|
// className,
|
||||||
showTimeAgo = true,
|
// showTimeAgo = true,
|
||||||
status,
|
// status,
|
||||||
isUserReport = false
|
// isUserReport = false,
|
||||||
}: IIncidentCardProps) {
|
// incidentId
|
||||||
// Helper to normalize severity to a number
|
// }: IIncidentCardProps) {
|
||||||
const normalizeSeverity = (sev: number | "Low" | "Medium" | "High" | "Critical"): number => {
|
// const router = useRouter()
|
||||||
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 getSeverityColor = (severity: number) => {
|
// // Helper to normalize severity to a number
|
||||||
switch (severity) {
|
// const normalizeSeverity = (sev: number | "Low" | "Medium" | "High" | "Critical"): number => {
|
||||||
case 4:
|
// if (typeof sev === "number") return sev;
|
||||||
return "border-purple-600 bg-purple-50 dark:bg-purple-950/30"
|
// switch (sev) {
|
||||||
case 3:
|
// case "Critical":
|
||||||
return "border-red-500 bg-red-50 dark:bg-red-950/30"
|
// return 4;
|
||||||
case 2:
|
// case "High":
|
||||||
return "border-yellow-400 bg-yellow-50 dark:bg-yellow-950/30"
|
// return 3;
|
||||||
case 1:
|
// case "Medium":
|
||||||
default:
|
// return 2;
|
||||||
return "border-blue-500 bg-blue-50 dark:bg-blue-950/30"
|
// case "Low":
|
||||||
}
|
// default:
|
||||||
}
|
// return 1;
|
||||||
|
// }
|
||||||
|
// };
|
||||||
|
|
||||||
const getSeverityText = (severity: number) => {
|
// const getSeverityColor = (severity: number) => {
|
||||||
switch (severity) {
|
// switch (severity) {
|
||||||
case 4:
|
// case 4:
|
||||||
return "text-purple-600 dark:text-purple-400"
|
// return "border-purple-600 bg-purple-50 dark:bg-purple-950/30"
|
||||||
case 3:
|
// case 3:
|
||||||
return "text-red-500 dark:text-red-400"
|
// return "border-red-500 bg-red-50 dark:bg-red-950/30"
|
||||||
case 2:
|
// case 2:
|
||||||
return "text-yellow-600 dark:text-yellow-400"
|
// return "border-yellow-400 bg-yellow-50 dark:bg-yellow-950/30"
|
||||||
case 1:
|
// case 1:
|
||||||
default:
|
// default:
|
||||||
return "text-blue-500 dark:text-blue-400"
|
// return "border-blue-500 bg-blue-50 dark:bg-blue-950/30"
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
const getSeverityLabel = (severity: number) => {
|
// const getSeverityText = (severity: number) => {
|
||||||
switch (severity) {
|
// switch (severity) {
|
||||||
case 4:
|
// case 4:
|
||||||
return "Critical"
|
// return "text-purple-600 dark:text-purple-400"
|
||||||
case 3:
|
// case 3:
|
||||||
return "High"
|
// return "text-red-500 dark:text-red-400"
|
||||||
case 2:
|
// case 2:
|
||||||
return "Medium"
|
// return "text-yellow-600 dark:text-yellow-400"
|
||||||
case 1:
|
// case 1:
|
||||||
default:
|
// default:
|
||||||
return "Low"
|
// return "text-blue-500 dark:text-blue-400"
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
// const getSeverityLabel = (severity: number) => {
|
||||||
switch (status?.toLowerCase()) {
|
// switch (severity) {
|
||||||
case "resolved":
|
// case 4:
|
||||||
return "bg-green-100 text-green-700 border-green-300 dark:bg-green-950/30 dark:text-green-400 dark:border-green-800"
|
// return "Critical"
|
||||||
case "pending":
|
// case 3:
|
||||||
default:
|
// return "High"
|
||||||
return "bg-blue-100 text-blue-700 border-blue-300 dark:bg-blue-950/30 dark:text-blue-400 dark:border-blue-800"
|
// case 2:
|
||||||
}
|
// return "Medium"
|
||||||
}
|
// case 1:
|
||||||
|
// default:
|
||||||
|
// return "Low"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
const normalizedSeverity = normalizeSeverity(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"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
return (
|
// const normalizedSeverity = normalizeSeverity(severity);
|
||||||
<Card
|
|
||||||
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-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>
|
// // Handle card click - either use provided onClick or navigate to incident detail page
|
||||||
|
// const handleClick = (e: React.MouseEvent) => {
|
||||||
|
// if (onClick) {
|
||||||
|
// onClick()
|
||||||
|
// } else if (incidentId) {
|
||||||
|
// // Open incident detail in new tab
|
||||||
|
// window.open(`/incidents/detail/${incidentId}`, '_blank')
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-y-2 gap-x-3 text-sm text-muted-foreground">
|
// return (
|
||||||
<div className="flex items-center gap-1.5">
|
// <Card
|
||||||
<Clock className="h-4 w-4 shrink-0" />
|
// className={cn(
|
||||||
<span>{time}</span>
|
// "overflow-hidden transition-all duration-200 border-l-4",
|
||||||
</div>
|
// getSeverityColor(normalizedSeverity),
|
||||||
|
// "hover:shadow-md hover:translate-y-[-2px]",
|
||||||
|
// "group cursor-pointer",
|
||||||
|
// className
|
||||||
|
// )}
|
||||||
|
// onClick={handleClick}
|
||||||
|
// >
|
||||||
|
// <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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
// <h3 className="font-semibold text-base mb-2 line-clamp-1">{title}</h3>
|
||||||
<MapPin className="h-4 w-4 shrink-0" />
|
|
||||||
<span className="truncate max-w-[180px]">{location}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add status badges for user reports */}
|
// <div className="flex flex-wrap gap-y-2 gap-x-3 text-sm text-muted-foreground">
|
||||||
{isUserReport && typeof status === "string" && (
|
// <div className="flex items-center gap-1.5">
|
||||||
<Badge variant="outline" className={`mt-3 ${getStatusColor(status)}`}>
|
// <Clock className="h-4 w-4 shrink-0" />
|
||||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
// <span>{time}</span>
|
||||||
</Badge>
|
// </div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
// <div className="flex items-center gap-1.5">
|
||||||
<ChevronRight className="h-5 w-5 text-muted-foreground" />
|
// <MapPin className="h-4 w-4 shrink-0" />
|
||||||
</div>
|
// <span className="truncate max-w-[180px]">{location}</span>
|
||||||
</div>
|
// </div>
|
||||||
</CardContent>
|
// </div>
|
||||||
</Card>
|
|
||||||
)
|
// {/* 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({
|
export function IncidentCardV2({
|
||||||
title,
|
title,
|
||||||
|
@ -163,75 +179,22 @@ export function IncidentCardV2({
|
||||||
showTimeAgo = true,
|
showTimeAgo = true,
|
||||||
status,
|
status,
|
||||||
isUserReport = false,
|
isUserReport = false,
|
||||||
|
incidentId
|
||||||
}: IIncidentCardProps) {
|
}: IIncidentCardProps) {
|
||||||
// Helper to normalize severity to a number
|
const router = useRouter()
|
||||||
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)
|
const normalizedSeverity = normalizeSeverity(severity)
|
||||||
|
|
||||||
|
// Handle card click - either use provided onClick or navigate to incident detail page
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
if (onClick) {
|
||||||
|
onClick()
|
||||||
|
} else if (incidentId) {
|
||||||
|
// Open incident detail in new tab
|
||||||
|
window.open(`/incidents/detail/${incidentId}`, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
|
@ -241,7 +204,7 @@ export function IncidentCardV2({
|
||||||
"group cursor-pointer",
|
"group cursor-pointer",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="flex items-stretch">
|
<div className="flex items-stretch">
|
||||||
|
@ -271,7 +234,7 @@ export function IncidentCardV2({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isUserReport && typeof status === "string" && (
|
{isUserReport && typeof status === "string" && (
|
||||||
<Badge variant="outline" className={`${getStatusColor(status)}`}>
|
<Badge variant="outline" className={`${getStatusInfo(status)}`}>
|
||||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -35,6 +35,7 @@ import { SidebarInfoTab } from "./tabs/info-tab";
|
||||||
import { SidebarStatisticsTab } from "./tabs/statistics-tab";
|
import { SidebarStatisticsTab } from "./tabs/statistics-tab";
|
||||||
import { useCrimeAnalytics } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics";
|
import { useCrimeAnalytics } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics";
|
||||||
import { usePagination } from "@/app/_hooks/use-pagination";
|
import { usePagination } from "@/app/_hooks/use-pagination";
|
||||||
|
import { ICrimeSourceTypes } from "@/app/_utils/types/map";
|
||||||
|
|
||||||
interface CrimeSidebarProps {
|
interface CrimeSidebarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
@ -45,7 +46,7 @@ interface CrimeSidebarProps {
|
||||||
crimes: ICrimes[];
|
crimes: ICrimes[];
|
||||||
recentIncidents?: IIncidentLogs[]; // User reports from last 24 hours
|
recentIncidents?: IIncidentLogs[]; // User reports from last 24 hours
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
sourceType?: string;
|
sourceType?: ICrimeSourceTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrimeSidebar({
|
export default function CrimeSidebar({
|
||||||
|
|
|
@ -0,0 +1,284 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ArrowLeft, Calendar, Clock, MapPin, AlertCircle, FileText, Shield, User } from "lucide-react";
|
||||||
|
import { Card, CardContent, CardHeader, CardFooter, CardTitle } from "@/app/_components/ui/card";
|
||||||
|
import { Button } from "@/app/_components/ui/button";
|
||||||
|
import { Badge } from "@/app/_components/ui/badge";
|
||||||
|
import { Separator } from "@/app/_components/ui/separator";
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import Image from "next/image";
|
||||||
|
import { cn } from "@/app/_lib/utils";
|
||||||
|
|
||||||
|
|
||||||
|
// Use the same IIncident interface as in incidents-tab.tsx
|
||||||
|
interface IIncident {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
address: string;
|
||||||
|
timestamp: string | Date;
|
||||||
|
district?: string;
|
||||||
|
severity?: number | "Low" | "Medium" | "High" | "Critical";
|
||||||
|
status?: string | true;
|
||||||
|
description?: string;
|
||||||
|
location?: {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
};
|
||||||
|
reporter?: string;
|
||||||
|
reporter_id?: string;
|
||||||
|
images?: string[];
|
||||||
|
responding_unit?: string;
|
||||||
|
resolution?: string;
|
||||||
|
created_at?: string | Date;
|
||||||
|
updated_at?: string | Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IIncidentDetailTabProps {
|
||||||
|
incident: IIncident;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default function IncidentDetailTab({ incident, onBack }: IIncidentDetailTabProps) {
|
||||||
|
// Format the timestamp for display
|
||||||
|
const formatDate = (date: string | Date) => {
|
||||||
|
if (!date) return "Unknown date";
|
||||||
|
try {
|
||||||
|
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return format(dateObj, 'MMMM d, yyyy - HH:mm');
|
||||||
|
} catch (error) {
|
||||||
|
return "Invalid date";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeSeverity = (sev: number | "Low" | "Medium" | "High" | "Critical" | undefined): string => {
|
||||||
|
if (typeof sev === "string") return sev;
|
||||||
|
switch (sev) {
|
||||||
|
case 4:
|
||||||
|
return "Critical";
|
||||||
|
case 3:
|
||||||
|
return "High";
|
||||||
|
case 2:
|
||||||
|
return "Medium";
|
||||||
|
case 1:
|
||||||
|
return "Low";
|
||||||
|
default:
|
||||||
|
return "Low";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSeverityGradient = (severity: string) => {
|
||||||
|
switch (severity) {
|
||||||
|
case "Critical":
|
||||||
|
return "bg-gradient-to-r from-purple-500/10 to-purple-600/5 dark:from-purple-900/20 dark:to-purple-800/10"
|
||||||
|
case "High":
|
||||||
|
return "bg-gradient-to-r from-red-500/10 to-red-600/5 dark:from-red-900/20 dark:to-red-800/10"
|
||||||
|
case "Medium":
|
||||||
|
return "bg-gradient-to-r from-yellow-500/10 to-yellow-600/5 dark:from-yellow-900/20 dark:to-yellow-800/10"
|
||||||
|
case "Low":
|
||||||
|
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 getStatusInfo = (status?: string | true) => {
|
||||||
|
if (status === true) {
|
||||||
|
return {
|
||||||
|
text: "Verified",
|
||||||
|
color: "bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-400"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return {
|
||||||
|
text: "Pending",
|
||||||
|
color: "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case "resolved":
|
||||||
|
return {
|
||||||
|
text: "Resolved",
|
||||||
|
color: "bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-400"
|
||||||
|
};
|
||||||
|
case "in progress":
|
||||||
|
return {
|
||||||
|
text: "In Progress",
|
||||||
|
color: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400"
|
||||||
|
};
|
||||||
|
case "pending":
|
||||||
|
return {
|
||||||
|
text: "Pending",
|
||||||
|
color: "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400"
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
text: status.charAt(0).toUpperCase() + status.slice(1),
|
||||||
|
color: "bg-gray-100 text-gray-800 border-gray-200 dark:bg-gray-900/30 dark:text-gray-400"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Get appropriate color for severity badge
|
||||||
|
const getSeverityColor = (severity: string) => {
|
||||||
|
switch (severity) {
|
||||||
|
case "Critical": return "bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-400";
|
||||||
|
case "High": return "bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400";
|
||||||
|
case "Medium": return "bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400";
|
||||||
|
case "Low": return "bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-400";
|
||||||
|
default: return "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const severityText = normalizeSeverity(incident.severity);
|
||||||
|
const severityColor = getSeverityColor(severityText);
|
||||||
|
const statusInfo = getStatusInfo(incident.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header with back button */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onBack}
|
||||||
|
className="h-8 gap-1 text-sidebar-foreground/70 hover:text-sidebar-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
<span>Back to incidents</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main incident info card */}
|
||||||
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden transition-all duration-300 border",
|
||||||
|
getSeverityGradient(severityText),
|
||||||
|
"hover:shadow-md hover:translate-y-[-2px]",
|
||||||
|
"group cursor-pointer",
|
||||||
|
)}>
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<CardTitle className="text-lg">{incident.category}</CardTitle>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Badge variant="outline" className={severityColor}>
|
||||||
|
{severityText}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="outline" className={statusInfo.color}>
|
||||||
|
{statusInfo.text}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="p-4 pt-2">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span className="text-sm">{formatDate(incident.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<MapPin className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm flex-1">
|
||||||
|
<p>{incident.address}</p>
|
||||||
|
{incident.district && <p className="text-muted-foreground">{incident.district}</p>}
|
||||||
|
{incident.location && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Coordinates: {incident.location.lat.toFixed(6)}, {incident.location.lng.toFixed(6)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h4 className="font-medium flex items-center gap-1.5">
|
||||||
|
<FileText className="h-4 w-4" /> Description
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">
|
||||||
|
{incident.description || "No description provided."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(incident.reporter || incident.reporter_id) && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<User className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span>Reported by: {incident.reporter || "Anonymous"}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{incident.responding_unit && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Shield className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<span>Responding unit: {incident.responding_unit}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="p-4 pt-2 flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>ID: {incident.id}</span>
|
||||||
|
{incident.created_at && (
|
||||||
|
<span>Created: {formatDate(incident.created_at)}</span>
|
||||||
|
)}
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Display resolution information if available */}
|
||||||
|
{incident.resolution && (
|
||||||
|
<Card className="border border-border bg-card">
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<CardTitle className="text-sm flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="h-4 w-4 text-green-500" />
|
||||||
|
Resolution
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-2">
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{incident.resolution}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Display images if available */}
|
||||||
|
{incident.images && incident.images.length > 0 && (
|
||||||
|
<Card className="border border-border bg-card mt-4">
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<CardTitle className="text-sm">Evidence Images</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{incident.images.map((image, index) => (
|
||||||
|
<div key={index} className="aspect-square bg-muted rounded-md overflow-hidden">
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt={`Evidence ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
@ -28,9 +28,11 @@ import {
|
||||||
getTimeAgo,
|
getTimeAgo,
|
||||||
} from "@/app/_utils/common";
|
} from "@/app/_utils/common";
|
||||||
import { SystemStatusCard } from "../components/system-status-card";
|
import { SystemStatusCard } from "../components/system-status-card";
|
||||||
import { IncidentCard, IncidentCardV2 } from "../components/incident-card";
|
import { IncidentCardV2 } from "../components/incident-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 { IIncidentLogs } from "@/app/_utils/types/crimes";
|
import { IIncidentLogs } from "@/app/_utils/types/crimes";
|
||||||
|
import IncidentDetailTab from "./incident-detail-tab";
|
||||||
|
import { ICrimeSourceTypes } from "@/app/_utils/types/map";
|
||||||
|
|
||||||
interface Incident {
|
interface Incident {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -62,6 +64,7 @@ interface SidebarIncidentsTabProps {
|
||||||
activeIncidentTab: string;
|
activeIncidentTab: string;
|
||||||
setActiveIncidentTab: (tab: string) => void;
|
setActiveIncidentTab: (tab: string) => void;
|
||||||
recentIncidents?: IIncidentLogs[]; // User reports from last 24 hours
|
recentIncidents?: IIncidentLogs[]; // User reports from last 24 hours
|
||||||
|
sourceType?: ICrimeSourceTypes; // Data source type (CBT or CBU)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarIncidentsTab({
|
export function SidebarIncidentsTab({
|
||||||
|
@ -80,11 +83,14 @@ export function SidebarIncidentsTab({
|
||||||
setActiveIncidentTab,
|
setActiveIncidentTab,
|
||||||
sourceType = "cbt",
|
sourceType = "cbt",
|
||||||
recentIncidents = [],
|
recentIncidents = [],
|
||||||
}: SidebarIncidentsTabProps & { sourceType?: string }) {
|
}: SidebarIncidentsTabProps) {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
const isCurrentYear = selectedYear === currentYear ||
|
const isCurrentYear = selectedYear === currentYear ||
|
||||||
selectedYear === "all";
|
selectedYear === "all";
|
||||||
|
|
||||||
|
const [selectedIncidentDetail, setSelectedIncidentDetail] = useState<Incident | null>(null);
|
||||||
|
const [showDetailView, setShowDetailView] = useState(false);
|
||||||
|
|
||||||
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])
|
||||||
|
@ -131,6 +137,33 @@ export function SidebarIncidentsTab({
|
||||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSwicthToCurrentYear = () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("set-year", {
|
||||||
|
detail: currentYear,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSwitchDataSource = () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("set-data-source", {
|
||||||
|
detail: sourceType === "cbt" ? "cbu" : "cbt",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIncidentCardClick = (incident: Incident) => {
|
||||||
|
setSelectedIncidentDetail(incident);
|
||||||
|
setShowDetailView(true);
|
||||||
|
handleIncidentClick(incident);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackToList = () => {
|
||||||
|
setShowDetailView(false);
|
||||||
|
setSelectedIncidentDetail(null);
|
||||||
|
};
|
||||||
|
|
||||||
// If source type is CBU, display warning instead of regular content
|
// If source type is CBU, display warning instead of regular content
|
||||||
if (sourceType === "cbu") {
|
if (sourceType === "cbu") {
|
||||||
return (
|
return (
|
||||||
|
@ -180,6 +213,7 @@ export function SidebarIncidentsTab({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-emerald-500/50 hover:bg-emerald-500/20 text-emerald-300"
|
className="border-emerald-500/50 hover:bg-emerald-500/20 text-emerald-300"
|
||||||
|
onClick={handleSwitchDataSource}
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Change Data Source
|
Change Data Source
|
||||||
|
@ -198,6 +232,15 @@ export function SidebarIncidentsTab({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showDetailView && selectedIncidentDetail) {
|
||||||
|
return (
|
||||||
|
<IncidentDetailTab
|
||||||
|
incident={selectedIncidentDetail}
|
||||||
|
onBack={handleBackToList}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Enhanced info card */}
|
{/* Enhanced info card */}
|
||||||
|
@ -329,12 +372,8 @@ export function SidebarIncidentsTab({
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-xs border-amber-500/50 bg-amber-500/10 hover:bg-amber-500/20 text-amber-300"
|
className="text-xs border-amber-500/50 bg-amber-500/10 hover:bg-amber-500/20 text-amber-300"
|
||||||
onClick={() =>
|
onClick={handleSwicthToCurrentYear}
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("set-year", {
|
|
||||||
detail: currentYear,
|
|
||||||
}),
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Switch to {currentYear}
|
Switch to {currentYear}
|
||||||
|
@ -368,6 +407,7 @@ export function SidebarIncidentsTab({
|
||||||
) => (
|
) => (
|
||||||
<IncidentCardV2
|
<IncidentCardV2
|
||||||
key={incident.id}
|
key={incident.id}
|
||||||
|
incidentId={incident.id} // Pass incident ID for navigation
|
||||||
title={`${incident.category || "Unknown"
|
title={`${incident.category || "Unknown"
|
||||||
} in ${incident.address?.split(",")[0] ||
|
} in ${incident.address?.split(",")[0] ||
|
||||||
"Unknown Location"
|
"Unknown Location"
|
||||||
|
@ -381,9 +421,7 @@ export function SidebarIncidentsTab({
|
||||||
"Unknown Location"}
|
"Unknown Location"}
|
||||||
severity={incident.severity}
|
severity={incident.severity}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleIncidentClick(
|
handleIncidentCardClick(incident as Incident)}
|
||||||
incident as Incident,
|
|
||||||
)}
|
|
||||||
status={incident.status}
|
status={incident.status}
|
||||||
isUserReport={true}
|
isUserReport={true}
|
||||||
/>
|
/>
|
||||||
|
@ -494,6 +532,7 @@ export function SidebarIncidentsTab({
|
||||||
) => (
|
) => (
|
||||||
<IncidentCardV2
|
<IncidentCardV2
|
||||||
key={incident.id}
|
key={incident.id}
|
||||||
|
incidentId={incident.id} // Pass incident ID for navigation
|
||||||
title={`${incident
|
title={`${incident
|
||||||
.category ||
|
.category ||
|
||||||
"Unknown"
|
"Unknown"
|
||||||
|
@ -520,7 +559,7 @@ export function SidebarIncidentsTab({
|
||||||
incident,
|
incident,
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleIncidentClick(
|
handleIncidentCardClick(
|
||||||
incident,
|
incident,
|
||||||
)}
|
)}
|
||||||
showTimeAgo={false}
|
showTimeAgo={false}
|
||||||
|
|
|
@ -287,6 +287,15 @@ export function SidebarStatisticsTab({
|
||||||
return { type, count, percentage }
|
return { type, count, percentage }
|
||||||
}) : []
|
}) : []
|
||||||
|
|
||||||
|
const handleSwitchDataSource = () => {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("set-data-source", {
|
||||||
|
detail: sourceType === "cbt" ? "cbu" : "cbt",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// If source type is CBU, display warning instead of regular content
|
// If source type is CBU, display warning instead of regular content
|
||||||
if (sourceType === "cbu") {
|
if (sourceType === "cbu") {
|
||||||
|
@ -324,6 +333,7 @@ export function SidebarStatisticsTab({
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-emerald-500/50 hover:bg-emerald-500/20 text-emerald-300"
|
className="border-emerald-500/50 hover:bg-emerald-500/20 text-emerald-300"
|
||||||
|
onClick={handleSwitchDataSource}
|
||||||
>
|
>
|
||||||
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
|
||||||
Change Data Source
|
Change Data Source
|
||||||
|
|
|
@ -10,10 +10,11 @@ import {
|
||||||
import { cn } from "@/app/_lib/utils"
|
import { cn } from "@/app/_lib/utils"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { Skeleton } from "../../ui/skeleton"
|
import { Skeleton } from "../../ui/skeleton"
|
||||||
|
import { ICrimeSourceTypes } from "@/app/_utils/types/map"
|
||||||
|
|
||||||
interface SourceTypeSelectorProps {
|
interface SourceTypeSelectorProps {
|
||||||
selectedSourceType: string
|
selectedSourceType: ICrimeSourceTypes
|
||||||
onSourceTypeChange: (sourceType: string) => void
|
onSourceTypeChange: (sourceType: ICrimeSourceTypes) => void
|
||||||
availableSourceTypes: string[]
|
availableSourceTypes: string[]
|
||||||
className?: string
|
className?: string
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
@ -46,7 +47,7 @@ export default function SourceTypeSelector({
|
||||||
) : (
|
) : (
|
||||||
<Select
|
<Select
|
||||||
value={selectedSourceType}
|
value={selectedSourceType}
|
||||||
onValueChange={(value) => onSourceTypeChange(value)}
|
onValueChange={(value: ICrimeSourceTypes) => onSourceTypeChange(value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={className}>
|
<SelectTrigger className={className}>
|
||||||
<SelectValue placeholder="Crime Category" />
|
<SelectValue placeholder="Crime Category" />
|
||||||
|
@ -56,9 +57,9 @@ export default function SourceTypeSelector({
|
||||||
style={{ zIndex: 2000 }}
|
style={{ zIndex: 2000 }}
|
||||||
className={`${className}`}
|
className={`${className}`}
|
||||||
>
|
>
|
||||||
{availableSourceTypes.map((category) => (
|
{availableSourceTypes.map((type) => (
|
||||||
<SelectItem key={category} value={category}>
|
<SelectItem key={type} value={type}>
|
||||||
{category}
|
{type === "cbt" ? "Crime by type" : type === "cbu" ? "Crime by unit" : type}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
|
@ -21,6 +21,7 @@ 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";
|
import SourceTypeSelector from "../source-type-selector";
|
||||||
|
import { ICrimeSourceTypes } from "@/app/_utils/types/map";
|
||||||
|
|
||||||
// Define the additional tools and features
|
// Define the additional tools and features
|
||||||
const additionalTooltips = [
|
const additionalTooltips = [
|
||||||
|
@ -45,8 +46,8 @@ 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;
|
selectedSourceType: ICrimeSourceTypes;
|
||||||
setSelectedSourceType: (sourceType: string) => void;
|
setSelectedSourceType: (sourceType: ICrimeSourceTypes) => void;
|
||||||
availableYears?: (number | null)[];
|
availableYears?: (number | null)[];
|
||||||
availableSourceTypes?: string[];
|
availableSourceTypes?: string[];
|
||||||
categories?: string[];
|
categories?: string[];
|
||||||
|
|
|
@ -6,6 +6,7 @@ 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 type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
import { ICrimeSourceTypes } from "@/app/_utils/types/map"
|
||||||
|
|
||||||
// Define the possible control IDs for the crime map
|
// Define the possible control IDs for the crime map
|
||||||
export type ITooltipsControl =
|
export type ITooltipsControl =
|
||||||
|
@ -38,8 +39,8 @@ export interface IMapTools {
|
||||||
interface TooltipProps {
|
interface TooltipProps {
|
||||||
onControlChange?: (controlId: ITooltipsControl) => void
|
onControlChange?: (controlId: ITooltipsControl) => void
|
||||||
activeControl?: string
|
activeControl?: string
|
||||||
selectedSourceType: string
|
selectedSourceType: ICrimeSourceTypes
|
||||||
setSelectedSourceType: (sourceType: string) => void
|
setSelectedSourceType: (sourceType: ICrimeSourceTypes) => void
|
||||||
availableSourceTypes: string[] // This must be string[] to match with API response
|
availableSourceTypes: string[] // This must be string[] to match with API response
|
||||||
selectedYear: number | "all"
|
selectedYear: number | "all"
|
||||||
setSelectedYear: (year: number | "all") => void
|
setSelectedYear: (year: number | "all") => void
|
||||||
|
|
|
@ -34,7 +34,7 @@ import Tooltips from "./controls/top/tooltips";
|
||||||
import Layers from "./layers/layers";
|
import Layers from "./layers/layers";
|
||||||
|
|
||||||
import { useGetUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries";
|
import { useGetUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries";
|
||||||
import { IDistrictFeature } from "@/app/_utils/types/map";
|
import { ICrimeSourceTypes, IDistrictFeature } from "@/app/_utils/types/map";
|
||||||
import EWSAlertLayer from "./layers/ews-alert-layer";
|
import EWSAlertLayer from "./layers/ews-alert-layer";
|
||||||
import { IIncidentLog } from "@/app/_utils/types/ews";
|
import { IIncidentLog } from "@/app/_utils/types/ews";
|
||||||
import {
|
import {
|
||||||
|
@ -54,7 +54,7 @@ export default function CrimeMap() {
|
||||||
const [selectedDistrict, setSelectedDistrict] = useState<
|
const [selectedDistrict, setSelectedDistrict] = useState<
|
||||||
IDistrictFeature | null
|
IDistrictFeature | null
|
||||||
>(null);
|
>(null);
|
||||||
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu");
|
const [selectedSourceType, setSelectedSourceType] = useState<ICrimeSourceTypes>("cbu");
|
||||||
const [selectedYear, setSelectedYear] = useState<number | "all">();
|
const [selectedYear, setSelectedYear] = useState<number | "all">();
|
||||||
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all");
|
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all");
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | "all">(
|
const [selectedCategory, setSelectedCategory] = useState<string | "all">(
|
||||||
|
@ -67,7 +67,7 @@ export default function CrimeMap() {
|
||||||
|
|
||||||
const [showAllIncidents, setShowAllIncidents] = useState(false);
|
const [showAllIncidents, setShowAllIncidents] = useState(false);
|
||||||
const [showUnitsLayer, setShowUnitsLayer] = useState(false);
|
const [showUnitsLayer, setShowUnitsLayer] = useState(false);
|
||||||
const [showClusters, setShowClusters] = useState(false);
|
const [showClustersLayer, setShowClustersLayer] = useState(false);
|
||||||
const [showHeatmap, setShowHeatmap] = useState(false);
|
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||||
const [showTimelineLayer, setShowTimelineLayer] = useState(false);
|
const [showTimelineLayer, setShowTimelineLayer] = useState(false);
|
||||||
const [showEWS, setShowEWS] = useState<boolean>(true);
|
const [showEWS, setShowEWS] = useState<boolean>(true);
|
||||||
|
@ -206,7 +206,7 @@ export default function CrimeMap() {
|
||||||
activeControl !== "alerts"
|
activeControl !== "alerts"
|
||||||
) {
|
) {
|
||||||
setActiveControl("clusters");
|
setActiveControl("clusters");
|
||||||
setShowClusters(true);
|
setShowClustersLayer(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [selectedSourceType, activeControl]);
|
}, [selectedSourceType, activeControl]);
|
||||||
|
@ -229,6 +229,20 @@ export default function CrimeMap() {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSetDataSource = (e: CustomEvent) => {
|
||||||
|
if (typeof e.detail === 'string') {
|
||||||
|
setSelectedSourceType(e.detail as ICrimeSourceTypes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('set-data-source', handleSetDataSource as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('set-data-source', handleSetDataSource as EventListener);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const handleTriggerAlert = useCallback(
|
const handleTriggerAlert = useCallback(
|
||||||
(priority: "high" | "medium" | "low") => {
|
(priority: "high" | "medium" | "low") => {
|
||||||
const newIncident = addMockIncident({ priority });
|
const newIncident = addMockIncident({ priority });
|
||||||
|
@ -251,7 +265,7 @@ export default function CrimeMap() {
|
||||||
setEwsIncidents(getAllIncidents());
|
setEwsIncidents(getAllIncidents());
|
||||||
}, [ewsIncidents]);
|
}, [ewsIncidents]);
|
||||||
|
|
||||||
const handleSourceTypeChange = useCallback((sourceType: string) => {
|
const handleSourceTypeChange = useCallback((sourceType: ICrimeSourceTypes) => {
|
||||||
setSelectedSourceType(sourceType);
|
setSelectedSourceType(sourceType);
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
@ -260,10 +274,10 @@ export default function CrimeMap() {
|
||||||
|
|
||||||
if (sourceType === "cbu") {
|
if (sourceType === "cbu") {
|
||||||
setActiveControl("clusters");
|
setActiveControl("clusters");
|
||||||
setShowClusters(true);
|
setShowClustersLayer(true);
|
||||||
} else {
|
} else {
|
||||||
setActiveControl("clusters");
|
setActiveControl("clusters");
|
||||||
setShowClusters(false);
|
setShowClustersLayer(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -321,9 +335,9 @@ export default function CrimeMap() {
|
||||||
setActiveControl(controlId);
|
setActiveControl(controlId);
|
||||||
|
|
||||||
if (controlId === "clusters") {
|
if (controlId === "clusters") {
|
||||||
setShowClusters(true);
|
setShowClustersLayer(true);
|
||||||
} else {
|
} else {
|
||||||
setShowClusters(false);
|
setShowClustersLayer(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controlId === "incidents") {
|
if (controlId === "incidents") {
|
||||||
|
@ -362,16 +376,16 @@ export default function CrimeMap() {
|
||||||
setShowEWS(true);
|
setShowEWS(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
console.log(`Current source type: ${selectedSourceType}`);
|
// console.log(`Current source type: ${selectedSourceType}`);
|
||||||
console.log(`Total crimes before filtering: ${crimes?.length || 0}`);
|
// console.log(`Total crimes before filtering: ${crimes?.length || 0}`);
|
||||||
console.log(
|
// console.log(
|
||||||
`Total crimes after source type filtering: ${crimesBySourceType.length}`,
|
// `Total crimes after source type filtering: ${crimesBySourceType.length}`,
|
||||||
);
|
// );
|
||||||
console.log(
|
// console.log(
|
||||||
`Total crimes after all filtering: ${filteredCrimes.length}`,
|
// `Total crimes after all filtering: ${filteredCrimes.length}`,
|
||||||
);
|
// );
|
||||||
}, [crimes, crimesBySourceType, filteredCrimes, selectedSourceType]);
|
// }, [crimes, crimesBySourceType, filteredCrimes, selectedSourceType]);
|
||||||
|
|
||||||
const disableYearMonth = activeControl === "incidents" || activeControl === "heatmap" || activeControl === "timeline"
|
const disableYearMonth = activeControl === "incidents" || activeControl === "heatmap" || activeControl === "timeline"
|
||||||
|
|
||||||
|
@ -500,11 +514,11 @@ export default function CrimeMap() {
|
||||||
sourceType={selectedSourceType}
|
sourceType={selectedSourceType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute bottom-20 right-0 z-20 p-2">
|
{showClustersLayer && (
|
||||||
{showClusters && (
|
<div className="absolute bottom-20 right-0 z-20 p-2">
|
||||||
<ClusterLegend position="bottom-right" />
|
<ClusterLegend position="bottom-right" />
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{showUnitsLayer && (
|
{showUnitsLayer && (
|
||||||
<div className="absolute bottom-20 right-0 z-10 p-2">
|
<div className="absolute bottom-20 right-0 z-10 p-2">
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useEffect, useCallback } from "react"
|
||||||
import 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/common"
|
||||||
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility"
|
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility"
|
||||||
|
|
||||||
interface ExtendedClusterLayerProps extends IClusterLayerProps {
|
interface ExtendedClusterLayerProps extends IClusterLayerProps {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { getCrimeRateColor, getFillOpacity } from "@/app/_utils/map"
|
import { getCrimeRateColor, getFillOpacity } from "@/app/_utils/map/common"
|
||||||
import type { IExtrusionLayerProps } from "@/app/_utils/types/map"
|
import type { IExtrusionLayerProps } from "@/app/_utils/types/map"
|
||||||
import { useEffect, useRef } from "react"
|
import { useEffect, useRef } from "react"
|
||||||
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility"
|
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility"
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { BASE_BEARING, BASE_DURATION, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map"
|
import { BASE_BEARING, BASE_DURATION, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map"
|
||||||
import { createFillColorExpression, getFillOpacity, processDistrictFeature } from "@/app/_utils/map"
|
import { createFillColorExpression, getFillOpacity, processDistrictFeature } from "@/app/_utils/map/common"
|
||||||
import type { IDistrictLayerProps } from "@/app/_utils/types/map"
|
import type { IDistrictLayerProps } from "@/app/_utils/types/map"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ export default function DistrictFillLineLayer({
|
||||||
visible = true,
|
visible = true,
|
||||||
map,
|
map,
|
||||||
onClick,
|
onClick,
|
||||||
onDistrictClick, // Add the new prop
|
onDistrictClick,
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
filterCategory = "all",
|
filterCategory = "all",
|
||||||
|
@ -21,12 +21,10 @@ export default function DistrictFillLineLayer({
|
||||||
showFill = true,
|
showFill = true,
|
||||||
activeControl,
|
activeControl,
|
||||||
}: IDistrictLayerProps & { onDistrictClick?: (district: any) => void }) {
|
}: IDistrictLayerProps & { onDistrictClick?: (district: any) => void }) {
|
||||||
// Extend the type inline
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !visible) return
|
if (!map || !visible) return
|
||||||
|
|
||||||
const handleDistrictClick = (e: any) => {
|
const handleDistrictClick = (e: any) => {
|
||||||
// Only include layers that exist in the map style
|
|
||||||
const possibleLayers = [
|
const possibleLayers = [
|
||||||
"unclustered-point",
|
"unclustered-point",
|
||||||
"clusters",
|
"clusters",
|
||||||
|
@ -42,7 +40,6 @@ export default function DistrictFillLineLayer({
|
||||||
})
|
})
|
||||||
|
|
||||||
if (incidentFeatures && incidentFeatures.length > 0) {
|
if (incidentFeatures && incidentFeatures.length > 0) {
|
||||||
// Click was on a marker or cluster, so don't process it as a district click
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -51,27 +48,22 @@ export default function DistrictFillLineLayer({
|
||||||
const feature = e.features[0]
|
const feature = e.features[0]
|
||||||
const districtId = feature.properties.kode_kec
|
const districtId = feature.properties.kode_kec
|
||||||
|
|
||||||
// If clicking the same district, deselect it
|
|
||||||
if (focusedDistrictId === districtId) {
|
if (focusedDistrictId === districtId) {
|
||||||
// Add null check for setFocusedDistrictId
|
|
||||||
if (setFocusedDistrictId) {
|
if (setFocusedDistrictId) {
|
||||||
setFocusedDistrictId(null)
|
setFocusedDistrictId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset pitch and bearing with animation
|
|
||||||
map.easeTo({
|
map.easeTo({
|
||||||
zoom: BASE_ZOOM,
|
zoom: BASE_ZOOM,
|
||||||
pitch: BASE_PITCH,
|
pitch: BASE_PITCH,
|
||||||
bearing: BASE_BEARING,
|
bearing: BASE_BEARING,
|
||||||
duration: BASE_DURATION,
|
duration: BASE_DURATION,
|
||||||
easing: (t) => t * (2 - t), // easeOutQuad
|
easing: (t) => t * (2 - t),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Restore fill color for all districts when unfocusing
|
|
||||||
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
|
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
|
||||||
map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
|
map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
|
||||||
|
|
||||||
// Show all clusters again when unfocusing
|
|
||||||
if (map.getLayer("clusters")) {
|
if (map.getLayer("clusters")) {
|
||||||
map.setLayoutProperty("clusters", "visibility", "visible")
|
map.setLayoutProperty("clusters", "visibility", "visible")
|
||||||
}
|
}
|
||||||
|
@ -81,37 +73,10 @@ export default function DistrictFillLineLayer({
|
||||||
|
|
||||||
return
|
return
|
||||||
} else if (focusedDistrictId) {
|
} else if (focusedDistrictId) {
|
||||||
// If we're already focusing on a district and clicking a different one,
|
|
||||||
// we need to reset the current one and move to the new one
|
|
||||||
if (setFocusedDistrictId) {
|
if (setFocusedDistrictId) {
|
||||||
setFocusedDistrictId(null)
|
setFocusedDistrictId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait a moment before selecting the new district to ensure clean transitions
|
|
||||||
// setTimeout(() => {
|
|
||||||
// const district = processDistrictFeature(feature, e, districtId, crimeDataByDistrict, crimes, year, month)
|
|
||||||
// if (!district || !setFocusedDistrictId) return
|
|
||||||
|
|
||||||
// setFocusedDistrictId(district.id)
|
|
||||||
|
|
||||||
// // Fly to the new district
|
|
||||||
// map.flyTo({
|
|
||||||
// center: [district.longitude, district.latitude],
|
|
||||||
// zoom: 12.5,
|
|
||||||
// pitch: 75,
|
|
||||||
// bearing: 0,
|
|
||||||
// duration: 1500,
|
|
||||||
// easing: (t) => t * (2 - t), // easeOutQuad
|
|
||||||
// })
|
|
||||||
|
|
||||||
// // Use onDistrictClick if available, otherwise fall back to onClick
|
|
||||||
// if (onDistrictClick) {
|
|
||||||
// onDistrictClick(district)
|
|
||||||
// } else if (onClick) {
|
|
||||||
// onClick(district)
|
|
||||||
// }
|
|
||||||
// }, 100)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,16 +84,13 @@ export default function DistrictFillLineLayer({
|
||||||
|
|
||||||
if (!district) return
|
if (!district) return
|
||||||
|
|
||||||
// Set the fill color expression immediately to show the focus
|
|
||||||
const focusedFillColorExpression = createFillColorExpression(district.id, crimeDataByDistrict)
|
const focusedFillColorExpression = createFillColorExpression(district.id, crimeDataByDistrict)
|
||||||
map.setPaintProperty("district-fill", "fill-color", focusedFillColorExpression as any)
|
map.setPaintProperty("district-fill", "fill-color", focusedFillColorExpression as any)
|
||||||
|
|
||||||
// Add null check for setFocusedDistrictId
|
|
||||||
if (setFocusedDistrictId) {
|
if (setFocusedDistrictId) {
|
||||||
setFocusedDistrictId(district.id)
|
setFocusedDistrictId(district.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide clusters when focusing on a district
|
|
||||||
if (map.getLayer("clusters")) {
|
if (map.getLayer("clusters")) {
|
||||||
map.setLayoutProperty("clusters", "visibility", "none")
|
map.setLayoutProperty("clusters", "visibility", "none")
|
||||||
}
|
}
|
||||||
|
@ -136,17 +98,6 @@ export default function DistrictFillLineLayer({
|
||||||
map.setLayoutProperty("unclustered-point", "visibility", "none")
|
map.setLayoutProperty("unclustered-point", "visibility", "none")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate to a pitched view focused on the district
|
|
||||||
// map.flyTo({
|
|
||||||
// center: [district.longitude, district.latitude],
|
|
||||||
// zoom: 12.5,
|
|
||||||
// pitch: 75,
|
|
||||||
// bearing: 0,
|
|
||||||
// duration: 1500,
|
|
||||||
// easing: (t) => t * (2 - t), // easeOutQuad
|
|
||||||
// })
|
|
||||||
|
|
||||||
// Use onDistrictClick if available, otherwise fall back to onClick
|
|
||||||
if (onDistrictClick) {
|
if (onDistrictClick) {
|
||||||
onDistrictClick(district)
|
onDistrictClick(district)
|
||||||
} else if (onClick) {
|
} else if (onClick) {
|
||||||
|
@ -173,9 +124,21 @@ export default function DistrictFillLineLayer({
|
||||||
url: `mapbox://${tilesetId}`,
|
url: `mapbox://${tilesetId}`,
|
||||||
})
|
})
|
||||||
|
|
||||||
const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict)
|
let fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict)
|
||||||
|
if (
|
||||||
|
Array.isArray(fillColorExpression) &&
|
||||||
|
fillColorExpression[0] === "match" &&
|
||||||
|
fillColorExpression.length < 4
|
||||||
|
) {
|
||||||
|
fillColorExpression = [
|
||||||
|
"match",
|
||||||
|
["get", "kode_kec"],
|
||||||
|
"",
|
||||||
|
"#90a4ae",
|
||||||
|
"#90a4ae"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
// Determine fill opacity based on active control
|
|
||||||
const fillOpacity = getFillOpacity(activeControl, showFill)
|
const fillOpacity = getFillOpacity(activeControl, showFill)
|
||||||
|
|
||||||
if (!map.getLayer("district-fill")) {
|
if (!map.getLayer("district-fill")) {
|
||||||
|
@ -223,10 +186,22 @@ export default function DistrictFillLineLayer({
|
||||||
map.on("click", "district-fill", handleDistrictClick)
|
map.on("click", "district-fill", handleDistrictClick)
|
||||||
} else {
|
} else {
|
||||||
if (map.getLayer("district-fill")) {
|
if (map.getLayer("district-fill")) {
|
||||||
const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict)
|
let fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict)
|
||||||
|
if (
|
||||||
|
Array.isArray(fillColorExpression) &&
|
||||||
|
fillColorExpression[0] === "match" &&
|
||||||
|
fillColorExpression.length < 4
|
||||||
|
) {
|
||||||
|
fillColorExpression = [
|
||||||
|
"match",
|
||||||
|
["get", "kode_kec"],
|
||||||
|
"",
|
||||||
|
"#90a4ae",
|
||||||
|
"#90a4ae"
|
||||||
|
]
|
||||||
|
}
|
||||||
map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
|
map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
|
||||||
|
|
||||||
// Update fill opacity when active control changes
|
|
||||||
const fillOpacity = getFillOpacity(activeControl, showFill)
|
const fillOpacity = getFillOpacity(activeControl, showFill)
|
||||||
map.setPaintProperty("district-fill", "fill-opacity", fillOpacity)
|
map.setPaintProperty("district-fill", "fill-opacity", fillOpacity)
|
||||||
}
|
}
|
||||||
|
@ -258,21 +233,32 @@ export default function DistrictFillLineLayer({
|
||||||
focusedDistrictId,
|
focusedDistrictId,
|
||||||
crimeDataByDistrict,
|
crimeDataByDistrict,
|
||||||
onClick,
|
onClick,
|
||||||
onDistrictClick, // Add to dependency array
|
onDistrictClick,
|
||||||
setFocusedDistrictId,
|
setFocusedDistrictId,
|
||||||
showFill,
|
showFill,
|
||||||
activeControl,
|
activeControl,
|
||||||
])
|
])
|
||||||
|
|
||||||
// Add an effect to update the fill color and opacity whenever relevant props change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !map.getLayer("district-fill")) return
|
if (!map || !map.getLayer("district-fill")) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict)
|
let fillColorExpression = createFillColorExpression(focusedDistrictId, crimeDataByDistrict)
|
||||||
|
if (
|
||||||
|
Array.isArray(fillColorExpression) &&
|
||||||
|
fillColorExpression[0] === "match" &&
|
||||||
|
fillColorExpression.length < 4
|
||||||
|
) {
|
||||||
|
fillColorExpression = [
|
||||||
|
"match",
|
||||||
|
["get", "kode_kec"],
|
||||||
|
"",
|
||||||
|
"#90a4ae",
|
||||||
|
"#90a4ae"
|
||||||
|
]
|
||||||
|
}
|
||||||
map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
|
map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
|
||||||
|
|
||||||
// Update fill opacity when active control changes
|
|
||||||
const fillOpacity = getFillOpacity(activeControl, showFill)
|
const fillOpacity = getFillOpacity(activeControl, showFill)
|
||||||
map.setPaintProperty("district-fill", "fill-opacity", fillOpacity)
|
map.setPaintProperty("district-fill", "fill-opacity", fillOpacity)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -18,12 +18,12 @@ import HeatmapLayer from "./heatmap-layer";
|
||||||
import TimelineLayer from "./timeline-layer";
|
import TimelineLayer from "./timeline-layer";
|
||||||
|
|
||||||
import type { ICrimes, IIncidentLogs } from "@/app/_utils/types/crimes";
|
import type { ICrimes, IIncidentLogs } from "@/app/_utils/types/crimes";
|
||||||
import type { IDistrictFeature } from "@/app/_utils/types/map";
|
import type { ICrimeSourceTypes, IDistrictFeature } from "@/app/_utils/types/map";
|
||||||
import {
|
import {
|
||||||
createFillColorExpression,
|
createFillColorExpression,
|
||||||
getCrimeRateColor,
|
getCrimeRateColor,
|
||||||
processCrimeDataByDistrict,
|
processCrimeDataByDistrict,
|
||||||
} from "@/app/_utils/map";
|
} from "@/app/_utils/map/common";
|
||||||
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { ITooltipsControl } from "../controls/top/tooltips";
|
import type { ITooltipsControl } from "../controls/top/tooltips";
|
||||||
|
@ -83,7 +83,7 @@ interface LayersProps {
|
||||||
tilesetId?: string;
|
tilesetId?: string;
|
||||||
useAllData?: boolean;
|
useAllData?: boolean;
|
||||||
showEWS?: boolean;
|
showEWS?: boolean;
|
||||||
sourceType?: string;
|
sourceType?: ICrimeSourceTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layers({
|
export default function Layers({
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
import { calculateDistances } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action'
|
import { calculateDistances } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Skeleton } from '../../ui/skeleton'
|
import { Skeleton } from '../../ui/skeleton'
|
||||||
import { formatDistance } from '@/app/_utils/map'
|
import { formatDistance } from '@/app/_utils/map/common'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import { $Enums } from '@prisma/client';
|
import { $Enums } from '@prisma/client';
|
||||||
import { CRIME_RATE_COLORS } from '@/app/_utils/const/map';
|
import { CRIME_RATE_COLORS } from '@/app/_utils/const/map';
|
||||||
import type { ICrimes } from '@/app/_utils/types/crimes';
|
import type { ICrimes } from '@/app/_utils/types/crimes';
|
||||||
import { IDistrictFeature } from './types/map';
|
|
||||||
import { ITooltipsControl } from '../_components/map/controls/top/tooltips';
|
import { AlertTriangle, Shield } from "lucide-react";
|
||||||
|
import { IDistrictFeature } from '../types/map';
|
||||||
|
import { ITooltipsControl } from '@/app/_components/map/controls/top/tooltips';
|
||||||
|
|
||||||
// Process crime data by district
|
// Process crime data by district
|
||||||
export const processCrimeDataByDistrict = (crimes: ICrimes[]) => {
|
export const processCrimeDataByDistrict = (crimes: ICrimes[]) => {
|
||||||
|
@ -49,15 +51,15 @@ export const createFillColorExpression = (
|
||||||
) => {
|
) => {
|
||||||
const colorEntries = focusedDistrictId
|
const colorEntries = focusedDistrictId
|
||||||
? [
|
? [
|
||||||
[
|
[
|
||||||
focusedDistrictId,
|
focusedDistrictId,
|
||||||
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId]?.level),
|
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId]?.level),
|
||||||
],
|
],
|
||||||
'rgba(0,0,0,0.05)',
|
'rgba(0,0,0,0.05)',
|
||||||
]
|
]
|
||||||
: Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
: Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
||||||
return [districtId, getCrimeRateColor(data.level)];
|
return [districtId, getCrimeRateColor(data.level)];
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'case',
|
'case',
|
||||||
|
@ -282,3 +284,124 @@ export function getFillOpacity(activeControl?: ITooltipsControl, showFill?: bool
|
||||||
// No fill for other controls, but keep boundaries visible
|
// No fill for other controls, but keep boundaries visible
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to normalize severity to a number
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeSeverityNumber = (sev?: number | "Low" | "Medium" | "High" | "Critical"): string => {
|
||||||
|
if (!sev) return "Medium";
|
||||||
|
|
||||||
|
if (typeof sev === "number") {
|
||||||
|
switch (sev) {
|
||||||
|
case 4: return "Critical";
|
||||||
|
case 3: return "High";
|
||||||
|
case 2: return "Medium";
|
||||||
|
case 1: return "Low";
|
||||||
|
default: return "Medium";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sev;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSeverityIcon = (severity: number) => {
|
||||||
|
switch (severity) {
|
||||||
|
case 4:
|
||||||
|
case 3:
|
||||||
|
return <AlertTriangle className={`h-6 w-6 ${getSeverityIconColor(severity)}`} />
|
||||||
|
case 2:
|
||||||
|
case 1:
|
||||||
|
default:
|
||||||
|
return <Shield className={`h-6 w-6 ${getSeverityIconColor(severity)}`} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getStatusInfo = (status?: string | true) => {
|
||||||
|
if (status === true) {
|
||||||
|
return {
|
||||||
|
text: "Verified",
|
||||||
|
color: "bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-400"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return {
|
||||||
|
text: "Pending",
|
||||||
|
color: "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case "resolved":
|
||||||
|
return {
|
||||||
|
text: "Resolved",
|
||||||
|
color: "bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-400"
|
||||||
|
};
|
||||||
|
case "in progress":
|
||||||
|
return {
|
||||||
|
text: "In Progress",
|
||||||
|
color: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-900/30 dark:text-amber-400"
|
||||||
|
};
|
||||||
|
case "pending":
|
||||||
|
return {
|
||||||
|
text: "Pending",
|
||||||
|
color: "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400"
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
text: status.charAt(0).toUpperCase() + status.slice(1),
|
||||||
|
color: "bg-gray-100 text-gray-800 border-gray-200 dark:bg-gray-900/30 dark:text-gray-400"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Get appropriate color for severity badge
|
||||||
|
export const getSeverityColor = (severity: string) => {
|
||||||
|
switch (severity) {
|
||||||
|
case "Critical": return "bg-red-100 text-red-800 border-red-200 dark:bg-red-900/30 dark:text-red-400";
|
||||||
|
case "High": return "bg-orange-100 text-orange-800 border-orange-200 dark:bg-orange-900/30 dark:text-orange-400";
|
||||||
|
case "Medium": return "bg-yellow-100 text-yellow-800 border-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400";
|
||||||
|
case "Low": return "bg-green-100 text-green-800 border-green-200 dark:bg-green-900/30 dark:text-green-400";
|
||||||
|
default: return "bg-blue-100 text-blue-800 border-blue-200 dark:bg-blue-900/30 dark:text-blue-400";
|
||||||
|
}
|
||||||
|
};
|
|
@ -107,3 +107,6 @@ export interface IUnclusteredPointLayerProps extends IBaseLayerProps {
|
||||||
focusedDistrictId: string | null;
|
focusedDistrictId: string | null;
|
||||||
showIncidentMarkers?: boolean; // Add this prop to control marker visibility
|
showIncidentMarkers?: boolean; // Add this prop to control marker visibility
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source type crimes
|
||||||
|
export type ICrimeSourceTypes = "cbt" | "cbu"
|
||||||
|
|
Loading…
Reference in New Issue