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:
vergiLgood1 2025-05-15 15:47:56 +07:00
parent 2ab60befd8
commit 223195e3fb
16 changed files with 741 additions and 315 deletions

View File

@ -3,6 +3,8 @@ import { Info, Clock, MapPin, AlertTriangle, ChevronRight, AlertCircle, Shield }
import { Card, CardContent, CardHeader, CardFooter } from "@/app/_components/ui/card"
import { Badge } from "@/app/_components/ui/badge"
import { cn } from "@/app/_lib/utils"
import { useRouter } from "next/navigation"
import { getSeverityGradient, getStatusInfo, normalizeSeverity } from '@/app/_utils/map/common'
interface IIncidentCardProps {
title: string
@ -14,144 +16,158 @@ interface IIncidentCardProps {
showTimeAgo?: boolean
status?: string | true
isUserReport?: boolean
incidentId?: string // Added incident ID for navigation
}
export function IncidentCard({
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;
}
};
// export function IncidentCard({
// title,
// location,
// time,
// severity = 1,
// onClick,
// className,
// showTimeAgo = true,
// status,
// isUserReport = false,
// incidentId
// }: IIncidentCardProps) {
// const router = useRouter()
const getSeverityColor = (severity: number) => {
switch (severity) {
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"
}
}
// // 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 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 getSeverityColor = (severity: number) => {
// switch (severity) {
// 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 getSeverityLabel = (severity: number) => {
switch (severity) {
case 4:
return "Critical"
case 3:
return "High"
case 2:
return "Medium"
case 1:
default:
return "Low"
}
}
// 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 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 getSeverityLabel = (severity: number) => {
// switch (severity) {
// case 4:
// return "Critical"
// case 3:
// return "High"
// 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 (
<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>
// const normalizedSeverity = normalizeSeverity(severity);
<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">
<div className="flex items-center gap-1.5">
<Clock className="h-4 w-4 shrink-0" />
<span>{time}</span>
</div>
// return (
// <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={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">
<MapPin className="h-4 w-4 shrink-0" />
<span className="truncate max-w-[180px]">{location}</span>
</div>
</div>
// <h3 className="font-semibold text-base mb-2 line-clamp-1">{title}</h3>
{/* 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="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="opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</div>
</CardContent>
</Card>
)
}
// <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,
@ -163,75 +179,22 @@ export function IncidentCardV2({
showTimeAgo = true,
status,
isUserReport = false,
incidentId
}: 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 router = useRouter()
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 (
<Card
className={cn(
@ -241,7 +204,7 @@ export function IncidentCardV2({
"group cursor-pointer",
className,
)}
onClick={onClick}
onClick={handleClick}
>
<CardContent className="p-0">
<div className="flex items-stretch">
@ -271,7 +234,7 @@ export function IncidentCardV2({
)}
{isUserReport && typeof status === "string" && (
<Badge variant="outline" className={`${getStatusColor(status)}`}>
<Badge variant="outline" className={`${getStatusInfo(status)}`}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</Badge>
)}

View File

@ -35,6 +35,7 @@ import { SidebarInfoTab } from "./tabs/info-tab";
import { SidebarStatisticsTab } from "./tabs/statistics-tab";
import { useCrimeAnalytics } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics";
import { usePagination } from "@/app/_hooks/use-pagination";
import { ICrimeSourceTypes } from "@/app/_utils/types/map";
interface CrimeSidebarProps {
className?: string;
@ -45,7 +46,7 @@ interface CrimeSidebarProps {
crimes: ICrimes[];
recentIncidents?: IIncidentLogs[]; // User reports from last 24 hours
isLoading?: boolean;
sourceType?: string;
sourceType?: ICrimeSourceTypes;
}
export default function CrimeSidebar({

View File

@ -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>
)}
</>
);
}

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import {
AlertCircle,
AlertTriangle,
@ -28,9 +28,11 @@ import {
getTimeAgo,
} from "@/app/_utils/common";
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 { IIncidentLogs } from "@/app/_utils/types/crimes";
import IncidentDetailTab from "./incident-detail-tab";
import { ICrimeSourceTypes } from "@/app/_utils/types/map";
interface Incident {
id: string;
@ -62,6 +64,7 @@ interface SidebarIncidentsTabProps {
activeIncidentTab: string;
setActiveIncidentTab: (tab: string) => void;
recentIncidents?: IIncidentLogs[]; // User reports from last 24 hours
sourceType?: ICrimeSourceTypes; // Data source type (CBT or CBU)
}
export function SidebarIncidentsTab({
@ -80,11 +83,14 @@ export function SidebarIncidentsTab({
setActiveIncidentTab,
sourceType = "cbt",
recentIncidents = [],
}: SidebarIncidentsTabProps & { sourceType?: string }) {
}: SidebarIncidentsTabProps) {
const currentYear = new Date().getFullYear();
const isCurrentYear = selectedYear === currentYear ||
selectedYear === "all";
const [selectedIncidentDetail, setSelectedIncidentDetail] = useState<Incident | null>(null);
const [showDetailView, setShowDetailView] = useState(false);
const topCategories = crimeStats.categoryCounts
? Object.entries(crimeStats.categoryCounts)
.sort((a, b) => b[1] - a[1])
@ -131,6 +137,33 @@ export function SidebarIncidentsTab({
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 (sourceType === "cbu") {
return (
@ -180,6 +213,7 @@ export function SidebarIncidentsTab({
variant="outline"
size="sm"
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" />
Change Data Source
@ -198,6 +232,15 @@ export function SidebarIncidentsTab({
);
}
if (showDetailView && selectedIncidentDetail) {
return (
<IncidentDetailTab
incident={selectedIncidentDetail}
onBack={handleBackToList}
/>
);
}
return (
<>
{/* Enhanced info card */}
@ -329,12 +372,8 @@ export function SidebarIncidentsTab({
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,
}),
)}
onClick={handleSwicthToCurrentYear}
>
<RefreshCw className="h-3.5 w-3.5 mr-1.5" />
Switch to {currentYear}
@ -368,6 +407,7 @@ export function SidebarIncidentsTab({
) => (
<IncidentCardV2
key={incident.id}
incidentId={incident.id} // Pass incident ID for navigation
title={`${incident.category || "Unknown"
} in ${incident.address?.split(",")[0] ||
"Unknown Location"
@ -381,9 +421,7 @@ export function SidebarIncidentsTab({
"Unknown Location"}
severity={incident.severity}
onClick={() =>
handleIncidentClick(
incident as Incident,
)}
handleIncidentCardClick(incident as Incident)}
status={incident.status}
isUserReport={true}
/>
@ -494,6 +532,7 @@ export function SidebarIncidentsTab({
) => (
<IncidentCardV2
key={incident.id}
incidentId={incident.id} // Pass incident ID for navigation
title={`${incident
.category ||
"Unknown"
@ -520,7 +559,7 @@ export function SidebarIncidentsTab({
incident,
)}
onClick={() =>
handleIncidentClick(
handleIncidentCardClick(
incident,
)}
showTimeAgo={false}

View File

@ -287,6 +287,15 @@ export function SidebarStatisticsTab({
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 (sourceType === "cbu") {
@ -324,6 +333,7 @@ export function SidebarStatisticsTab({
variant="outline"
size="sm"
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" />
Change Data Source

View File

@ -10,10 +10,11 @@ import {
import { cn } from "@/app/_lib/utils"
import { useEffect, useRef, useState } from "react"
import { Skeleton } from "../../ui/skeleton"
import { ICrimeSourceTypes } from "@/app/_utils/types/map"
interface SourceTypeSelectorProps {
selectedSourceType: string
onSourceTypeChange: (sourceType: string) => void
selectedSourceType: ICrimeSourceTypes
onSourceTypeChange: (sourceType: ICrimeSourceTypes) => void
availableSourceTypes: string[]
className?: string
isLoading?: boolean
@ -46,7 +47,7 @@ export default function SourceTypeSelector({
) : (
<Select
value={selectedSourceType}
onValueChange={(value) => onSourceTypeChange(value)}
onValueChange={(value: ICrimeSourceTypes) => onSourceTypeChange(value)}
>
<SelectTrigger className={className}>
<SelectValue placeholder="Crime Category" />
@ -56,9 +57,9 @@ export default function SourceTypeSelector({
style={{ zIndex: 2000 }}
className={`${className}`}
>
{availableSourceTypes.map((category) => (
<SelectItem key={category} value={category}>
{category}
{availableSourceTypes.map((type) => (
<SelectItem key={type} value={type}>
{type === "cbt" ? "Crime by type" : type === "cbu" ? "Crime by unit" : type}
</SelectItem>
))}
</SelectContent>

View File

@ -21,6 +21,7 @@ import MonthSelector from "../month-selector";
import YearSelector from "../year-selector";
import CategorySelector from "../category-selector";
import SourceTypeSelector from "../source-type-selector";
import { ICrimeSourceTypes } from "@/app/_utils/types/map";
// Define the additional tools and features
const additionalTooltips = [
@ -45,8 +46,8 @@ interface AdditionalTooltipsProps {
setSelectedMonth: (month: number | "all") => void;
selectedCategory: string | "all";
setSelectedCategory: (category: string | "all") => void;
selectedSourceType: string;
setSelectedSourceType: (sourceType: string) => void;
selectedSourceType: ICrimeSourceTypes;
setSelectedSourceType: (sourceType: ICrimeSourceTypes) => void;
availableYears?: (number | null)[];
availableSourceTypes?: string[];
categories?: string[];

View File

@ -6,6 +6,7 @@ import CrimeTooltips from "./crime-tooltips"
import AdditionalTooltips from "./additional-tooltips"
import SearchTooltip from "./search-control"
import type { ReactNode } from "react"
import { ICrimeSourceTypes } from "@/app/_utils/types/map"
// Define the possible control IDs for the crime map
export type ITooltipsControl =
@ -38,8 +39,8 @@ export interface IMapTools {
interface TooltipProps {
onControlChange?: (controlId: ITooltipsControl) => void
activeControl?: string
selectedSourceType: string
setSelectedSourceType: (sourceType: string) => void
selectedSourceType: ICrimeSourceTypes
setSelectedSourceType: (sourceType: ICrimeSourceTypes) => void
availableSourceTypes: string[] // This must be string[] to match with API response
selectedYear: number | "all"
setSelectedYear: (year: number | "all") => void

View File

@ -34,7 +34,7 @@ import Tooltips from "./controls/top/tooltips";
import Layers from "./layers/layers";
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 { IIncidentLog } from "@/app/_utils/types/ews";
import {
@ -54,7 +54,7 @@ export default function CrimeMap() {
const [selectedDistrict, setSelectedDistrict] = useState<
IDistrictFeature | null
>(null);
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu");
const [selectedSourceType, setSelectedSourceType] = useState<ICrimeSourceTypes>("cbu");
const [selectedYear, setSelectedYear] = useState<number | "all">();
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all");
const [selectedCategory, setSelectedCategory] = useState<string | "all">(
@ -67,7 +67,7 @@ export default function CrimeMap() {
const [showAllIncidents, setShowAllIncidents] = useState(false);
const [showUnitsLayer, setShowUnitsLayer] = useState(false);
const [showClusters, setShowClusters] = useState(false);
const [showClustersLayer, setShowClustersLayer] = useState(false);
const [showHeatmap, setShowHeatmap] = useState(false);
const [showTimelineLayer, setShowTimelineLayer] = useState(false);
const [showEWS, setShowEWS] = useState<boolean>(true);
@ -206,7 +206,7 @@ export default function CrimeMap() {
activeControl !== "alerts"
) {
setActiveControl("clusters");
setShowClusters(true);
setShowClustersLayer(true);
}
}
}, [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(
(priority: "high" | "medium" | "low") => {
const newIncident = addMockIncident({ priority });
@ -251,7 +265,7 @@ export default function CrimeMap() {
setEwsIncidents(getAllIncidents());
}, [ewsIncidents]);
const handleSourceTypeChange = useCallback((sourceType: string) => {
const handleSourceTypeChange = useCallback((sourceType: ICrimeSourceTypes) => {
setSelectedSourceType(sourceType);
const currentYear = new Date().getFullYear();
@ -260,10 +274,10 @@ export default function CrimeMap() {
if (sourceType === "cbu") {
setActiveControl("clusters");
setShowClusters(true);
setShowClustersLayer(true);
} else {
setActiveControl("clusters");
setShowClusters(false);
setShowClustersLayer(true);
}
}, []);
@ -321,9 +335,9 @@ export default function CrimeMap() {
setActiveControl(controlId);
if (controlId === "clusters") {
setShowClusters(true);
setShowClustersLayer(true);
} else {
setShowClusters(false);
setShowClustersLayer(false);
}
if (controlId === "incidents") {
@ -362,16 +376,16 @@ export default function CrimeMap() {
setShowEWS(true);
};
useEffect(() => {
console.log(`Current source type: ${selectedSourceType}`);
console.log(`Total crimes before filtering: ${crimes?.length || 0}`);
console.log(
`Total crimes after source type filtering: ${crimesBySourceType.length}`,
);
console.log(
`Total crimes after all filtering: ${filteredCrimes.length}`,
);
}, [crimes, crimesBySourceType, filteredCrimes, selectedSourceType]);
// useEffect(() => {
// console.log(`Current source type: ${selectedSourceType}`);
// console.log(`Total crimes before filtering: ${crimes?.length || 0}`);
// console.log(
// `Total crimes after source type filtering: ${crimesBySourceType.length}`,
// );
// console.log(
// `Total crimes after all filtering: ${filteredCrimes.length}`,
// );
// }, [crimes, crimesBySourceType, filteredCrimes, selectedSourceType]);
const disableYearMonth = activeControl === "incidents" || activeControl === "heatmap" || activeControl === "timeline"
@ -500,11 +514,11 @@ export default function CrimeMap() {
sourceType={selectedSourceType}
/>
<div className="absolute bottom-20 right-0 z-20 p-2">
{showClusters && (
{showClustersLayer && (
<div className="absolute bottom-20 right-0 z-20 p-2">
<ClusterLegend position="bottom-right" />
)}
</div>
</div>
)}
{showUnitsLayer && (
<div className="absolute bottom-20 right-0 z-10 p-2">

View File

@ -4,7 +4,7 @@ import { useEffect, useCallback } from "react"
import mapboxgl from "mapbox-gl"
import type { GeoJSON } from "geojson"
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"
interface ExtendedClusterLayerProps extends IClusterLayerProps {

View File

@ -1,6 +1,6 @@
"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 { useEffect, useRef } from "react"
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility"

View File

@ -1,7 +1,7 @@
"use client"
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 { useEffect } from "react"
@ -9,7 +9,7 @@ export default function DistrictFillLineLayer({
visible = true,
map,
onClick,
onDistrictClick, // Add the new prop
onDistrictClick,
year,
month,
filterCategory = "all",
@ -21,12 +21,10 @@ export default function DistrictFillLineLayer({
showFill = true,
activeControl,
}: IDistrictLayerProps & { onDistrictClick?: (district: any) => void }) {
// Extend the type inline
useEffect(() => {
if (!map || !visible) return
const handleDistrictClick = (e: any) => {
// Only include layers that exist in the map style
const possibleLayers = [
"unclustered-point",
"clusters",
@ -42,7 +40,6 @@ export default function DistrictFillLineLayer({
})
if (incidentFeatures && incidentFeatures.length > 0) {
// Click was on a marker or cluster, so don't process it as a district click
return
}
@ -51,27 +48,22 @@ export default function DistrictFillLineLayer({
const feature = e.features[0]
const districtId = feature.properties.kode_kec
// If clicking the same district, deselect it
if (focusedDistrictId === districtId) {
// Add null check for setFocusedDistrictId
if (setFocusedDistrictId) {
setFocusedDistrictId(null)
}
// Reset pitch and bearing with animation
map.easeTo({
zoom: BASE_ZOOM,
pitch: BASE_PITCH,
bearing: BASE_BEARING,
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)
map.setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
// Show all clusters again when unfocusing
if (map.getLayer("clusters")) {
map.setLayoutProperty("clusters", "visibility", "visible")
}
@ -81,37 +73,10 @@ export default function DistrictFillLineLayer({
return
} 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) {
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
}
@ -119,16 +84,13 @@ export default function DistrictFillLineLayer({
if (!district) return
// Set the fill color expression immediately to show the focus
const focusedFillColorExpression = createFillColorExpression(district.id, crimeDataByDistrict)
map.setPaintProperty("district-fill", "fill-color", focusedFillColorExpression as any)
// Add null check for setFocusedDistrictId
if (setFocusedDistrictId) {
setFocusedDistrictId(district.id)
}
// Hide clusters when focusing on a district
if (map.getLayer("clusters")) {
map.setLayoutProperty("clusters", "visibility", "none")
}
@ -136,17 +98,6 @@ export default function DistrictFillLineLayer({
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) {
onDistrictClick(district)
} else if (onClick) {
@ -173,9 +124,21 @@ export default function DistrictFillLineLayer({
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)
if (!map.getLayer("district-fill")) {
@ -223,10 +186,22 @@ export default function DistrictFillLineLayer({
map.on("click", "district-fill", handleDistrictClick)
} else {
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)
// Update fill opacity when active control changes
const fillOpacity = getFillOpacity(activeControl, showFill)
map.setPaintProperty("district-fill", "fill-opacity", fillOpacity)
}
@ -258,21 +233,32 @@ export default function DistrictFillLineLayer({
focusedDistrictId,
crimeDataByDistrict,
onClick,
onDistrictClick, // Add to dependency array
onDistrictClick,
setFocusedDistrictId,
showFill,
activeControl,
])
// Add an effect to update the fill color and opacity whenever relevant props change
useEffect(() => {
if (!map || !map.getLayer("district-fill")) return
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)
// Update fill opacity when active control changes
const fillOpacity = getFillOpacity(activeControl, showFill)
map.setPaintProperty("district-fill", "fill-opacity", fillOpacity)
} catch (error) {

View File

@ -18,12 +18,12 @@ import HeatmapLayer from "./heatmap-layer";
import TimelineLayer from "./timeline-layer";
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 {
createFillColorExpression,
getCrimeRateColor,
processCrimeDataByDistrict,
} from "@/app/_utils/map";
} from "@/app/_utils/map/common";
import { toast } from "sonner";
import type { ITooltipsControl } from "../controls/top/tooltips";
@ -83,7 +83,7 @@ interface LayersProps {
tilesetId?: string;
useAllData?: boolean;
showEWS?: boolean;
sourceType?: string;
sourceType?: ICrimeSourceTypes;
}
export default function Layers({

View File

@ -3,7 +3,7 @@
import { calculateDistances } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action'
import { useState, useEffect } from 'react'
import { Skeleton } from '../../ui/skeleton'
import { formatDistance } from '@/app/_utils/map'
import { formatDistance } from '@/app/_utils/map/common'
import { useQuery } from '@tanstack/react-query'

View File

@ -1,8 +1,10 @@
import { $Enums } from '@prisma/client';
import { CRIME_RATE_COLORS } from '@/app/_utils/const/map';
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
export const processCrimeDataByDistrict = (crimes: ICrimes[]) => {
@ -49,15 +51,15 @@ export const createFillColorExpression = (
) => {
const colorEntries = focusedDistrictId
? [
[
focusedDistrictId,
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId]?.level),
],
'rgba(0,0,0,0.05)',
]
[
focusedDistrictId,
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId]?.level),
],
'rgba(0,0,0,0.05)',
]
: Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
return [districtId, getCrimeRateColor(data.level)];
});
return [districtId, getCrimeRateColor(data.level)];
});
return [
'case',
@ -282,3 +284,124 @@ export function getFillOpacity(activeControl?: ITooltipsControl, showFill?: bool
// No fill for other controls, but keep boundaries visible
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";
}
};

View File

@ -107,3 +107,6 @@ export interface IUnclusteredPointLayerProps extends IBaseLayerProps {
focusedDistrictId: string | null;
showIncidentMarkers?: boolean; // Add this prop to control marker visibility
}
// Source type crimes
export type ICrimeSourceTypes = "cbt" | "cbu"