Refactor map layers: consolidate imports, enhance type definitions, and improve layer management. Remove UnclusteredPointLayer and introduce AllIncidentsLayer for better incident handling and visualization.

This commit is contained in:
vergiLgood1 2025-05-15 12:21:24 +07:00
parent 952bdbed8f
commit 4590e21c39
14 changed files with 1574 additions and 1272 deletions

View File

@ -1,33 +1,50 @@
"use client" "use client";
import React, { useState, useEffect } from "react" import React, { useEffect, useState } from "react";
import { AlertTriangle, Calendar, Clock, MapPin, X, ChevronRight } from 'lucide-react' import {
import { Card, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card" AlertTriangle,
import { cn } from "@/app/_lib/utils" Calendar,
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/app/_components/ui/tabs" ChevronRight,
import { Button } from "@/app/_components/ui/button" Clock,
import { Skeleton } from "@/app/_components/ui/skeleton" MapPin,
import { useMap } from "react-map-gl/mapbox" X,
import { ICrimes } from "@/app/_utils/types/crimes" } from "lucide-react";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/app/_components/ui/card";
import { cn } from "@/app/_lib/utils";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/app/_components/ui/tabs";
import { Button } from "@/app/_components/ui/button";
import { Skeleton } from "@/app/_components/ui/skeleton";
import { useMap } from "react-map-gl/mapbox";
import { ICrimes } from "@/app/_utils/types/crimes";
// Import sidebar components // Import sidebar components
import { SidebarIncidentsTab } from "./tabs/incidents-tab" import { SidebarIncidentsTab } from "./tabs/incidents-tab";
import { getMonthName } from "@/app/_utils/common" import { getMonthName } from "@/app/_utils/common";
import { SidebarInfoTab } from "./tabs/info-tab" 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";
interface CrimeSidebarProps { interface CrimeSidebarProps {
className?: string className?: string;
defaultCollapsed?: boolean defaultCollapsed?: boolean;
selectedCategory?: string | "all" selectedCategory?: string | "all";
selectedYear: number selectedYear: number | "all";
selectedMonth?: number | "all" selectedMonth?: number | "all";
crimes: ICrimes[] crimes: ICrimes[];
isLoading?: boolean isLoading?: boolean;
sourceType?: string sourceType?: string;
} }
export default function CrimeSidebar({ export default function CrimeSidebar({
@ -40,81 +57,89 @@ export default function CrimeSidebar({
isLoading = false, isLoading = false,
sourceType = "cbt", sourceType = "cbt",
}: CrimeSidebarProps) { }: CrimeSidebarProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed) const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [activeTab, setActiveTab] = useState("incidents") const [activeTab, setActiveTab] = useState("incidents");
const [activeIncidentTab, setActiveIncidentTab] = useState("recent") const [activeIncidentTab, setActiveIncidentTab] = useState("recent");
const [currentTime, setCurrentTime] = useState<Date>(new Date()) const [currentTime, setCurrentTime] = useState<Date>(new Date());
const [location, setLocation] = useState<string>("Jember, East Java") const [location, setLocation] = useState<string>("Jember, East Java");
// Get the map instance to use for flyTo // Get the map instance to use for flyTo
const { current: map } = useMap() const { current: map } = useMap();
// Use custom hooks for analytics and pagination // Use custom hooks for analytics and pagination
const crimeStats = useCrimeAnalytics(crimes) const crimeStats = useCrimeAnalytics(crimes);
const { paginationState, handlePageChange } = usePagination(crimeStats.availableMonths) const { paginationState, handlePageChange } = usePagination(
crimeStats.availableMonths,
);
// Update current time every minute for the real-time display // Update current time every minute for the real-time display
useEffect(() => { useEffect(() => {
const timer = setInterval(() => { const timer = setInterval(() => {
setCurrentTime(new Date()) setCurrentTime(new Date());
}, 60000) }, 60000);
return () => clearInterval(timer) return () => clearInterval(timer);
}, []) }, []);
// Set default tab based on source type // Set default tab based on source type
useEffect(() => { useEffect(() => {
if (sourceType === "cbu") { if (sourceType === "cbu") {
setActiveTab("incidents") setActiveTab("incidents");
} }
}, [sourceType]) }, [sourceType]);
// Format date with selected year and month if provided // Format date with selected year and month if provided
const getDisplayDate = () => { const getDisplayDate = () => {
if (selectedMonth && selectedMonth !== 'all') { if (selectedMonth && selectedMonth !== "all") {
const date = new Date() const date = new Date();
date.setFullYear(selectedYear) date.setFullYear(
date.setMonth(Number(selectedMonth) - 1) typeof selectedYear === "number"
? selectedYear
: new Date().getFullYear(),
);
date.setMonth(Number(selectedMonth) - 1);
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat("en-US", {
year: 'numeric', year: "numeric",
month: 'long' month: "long",
}).format(date) }).format(date);
} }
return new Intl.DateTimeFormat('en-US', { return new Intl.DateTimeFormat("en-US", {
weekday: 'long', weekday: "long",
year: 'numeric', year: "numeric",
month: 'long', month: "long",
day: 'numeric' day: "numeric",
}).format(currentTime) }).format(currentTime);
} };
const formattedDate = getDisplayDate() const formattedDate = getDisplayDate();
const formattedTime = new Intl.DateTimeFormat('en-US', { const formattedTime = new Intl.DateTimeFormat("en-US", {
hour: '2-digit', hour: "2-digit",
minute: '2-digit', minute: "2-digit",
hour12: true hour12: true,
}).format(currentTime) }).format(currentTime);
const getTimePeriodDisplay = () => { const getTimePeriodDisplay = () => {
if (selectedMonth && selectedMonth !== 'all') { if (selectedMonth && selectedMonth !== "all") {
return `${getMonthName(Number(selectedMonth))} ${selectedYear}` return `${getMonthName(Number(selectedMonth))} ${selectedYear}`;
} }
return `${selectedYear} - All months` return `${selectedYear} - All months`;
} };
const handleIncidentClick = (incident: any) => { const handleIncidentClick = (incident: any) => {
if (!map || !incident.longitude || !incident.latitude) return if (!map || !incident.longitude || !incident.latitude) return;
} };
return ( return (
<div className={cn( <div
"fixed top-0 left-0 h-full z-40 transition-transform duration-300 ease-in-out bg-background border-r border-sidebar-border", className={cn(
isCollapsed ? "-translate-x-full" : "translate-x-0", "fixed top-0 left-0 h-full z-40 transition-transform duration-300 ease-in-out bg-background border-r border-sidebar-border",
className isCollapsed ? "-translate-x-full" : "translate-x-0",
)}> className,
)}
>
<div className="relative h-full flex items-stretch"> <div className="relative h-full flex items-stretch">
<div className="bg-background backdrop-blur-sm border-r border-sidebar-border h-full w-[420px]"> <div className="bg-background backdrop-blur-sm border-r border-sidebar-border h-full w-[420px]">
<div className="p-4 text-sidebar-foreground h-full flex flex-col max-h-full overflow-hidden"> <div className="p-4 text-sidebar-foreground h-full flex flex-col max-h-full overflow-hidden">
@ -159,7 +184,6 @@ export default function CrimeSidebar({
onValueChange={setActiveTab} onValueChange={setActiveTab}
> >
<TabsList className="w-full mb-4 bg-sidebar-accent p-1 rounded-full"> <TabsList className="w-full mb-4 bg-sidebar-accent p-1 rounded-full">
<TabsTrigger <TabsTrigger
value="incidents" value="incidents"
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground" className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
@ -182,59 +206,71 @@ export default function CrimeSidebar({
</TabsList> </TabsList>
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1 custom-scrollbar"> <div className="flex-1 overflow-y-auto overflow-x-hidden pr-1 custom-scrollbar">
{isLoading ? ( {isLoading
<div className="space-y-4"> ? (
<Skeleton className="h-24 w-full" /> <div className="space-y-4">
<div className="grid grid-cols-2 gap-2"> <Skeleton className="h-24 w-full" />
<Skeleton className="h-16 w-full" /> <div className="grid grid-cols-2 gap-2">
<Skeleton className="h-16 w-full" /> <Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" /> <Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" /> <Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
<div className="space-y-2">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</div>
</div> </div>
<div className="space-y-2"> )
<Skeleton className="h-20 w-full" /> : (
<Skeleton className="h-20 w-full" /> <>
<Skeleton className="h-20 w-full" /> <TabsContent
</div> value="incidents"
</div> className="m-0 p-0 space-y-4"
) : ( >
<> <SidebarIncidentsTab
crimeStats={crimeStats}
<TabsContent value="incidents" className="m-0 p-0 space-y-4"> formattedDate={formattedDate}
<SidebarIncidentsTab formattedTime={formattedTime}
crimeStats={crimeStats} location={location}
formattedDate={formattedDate} selectedMonth={selectedMonth}
formattedTime={formattedTime} selectedYear={selectedYear}
location={location} selectedCategory={selectedCategory}
selectedMonth={selectedMonth} getTimePeriodDisplay={getTimePeriodDisplay}
selectedYear={selectedYear} paginationState={paginationState}
selectedCategory={selectedCategory} handlePageChange={handlePageChange}
getTimePeriodDisplay={getTimePeriodDisplay} handleIncidentClick={handleIncidentClick}
paginationState={paginationState} activeIncidentTab={activeIncidentTab}
handlePageChange={handlePageChange} setActiveIncidentTab={setActiveIncidentTab}
handleIncidentClick={handleIncidentClick}
activeIncidentTab={activeIncidentTab}
setActiveIncidentTab={setActiveIncidentTab}
sourceType={sourceType} sourceType={sourceType}
// setActiveTab={setActiveTab} // Pass setActiveTab function // setActiveTab={setActiveTab} // Pass setActiveTab function
/> />
</TabsContent> </TabsContent>
<TabsContent value="statistics" className="m-0 p-0 space-y-4"> <TabsContent
<SidebarStatisticsTab value="statistics"
crimeStats={crimeStats} className="m-0 p-0 space-y-4"
selectedMonth={selectedMonth} >
selectedYear={selectedYear} <SidebarStatisticsTab
crimeStats={crimeStats}
selectedMonth={selectedMonth}
selectedYear={selectedYear}
sourceType={sourceType} sourceType={sourceType}
crimes={crimes} crimes={crimes}
/> />
</TabsContent> </TabsContent>
<TabsContent value="info" className="m-0 p-0 space-y-4"> <TabsContent
<SidebarInfoTab sourceType={sourceType} /> value="info"
</TabsContent> className="m-0 p-0 space-y-4"
</> >
)} <SidebarInfoTab
sourceType={sourceType}
/>
</TabsContent>
</>
)}
</div> </div>
</Tabs> </Tabs>
</div> </div>
@ -245,16 +281,22 @@ export default function CrimeSidebar({
className={cn( className={cn(
"absolute h-12 w-8 bg-background border-t border-b border-r border-sidebar-primary-foreground/30 flex items-center justify-center", "absolute h-12 w-8 bg-background border-t border-b border-r border-sidebar-primary-foreground/30 flex items-center justify-center",
"top-1/2 -translate-y-1/2 transition-all duration-300 ease-in-out", "top-1/2 -translate-y-1/2 transition-all duration-300 ease-in-out",
isCollapsed ? "-right-8 rounded-r-md" : "left-[420px] rounded-r-md", isCollapsed
? "-right-8 rounded-r-md"
: "left-[420px] rounded-r-md",
)} )}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"} aria-label={isCollapsed
? "Expand sidebar"
: "Collapse sidebar"}
> >
<ChevronRight <ChevronRight
className={cn("h-5 w-5 text-sidebar-primary-foreground transition-transform", className={cn(
!isCollapsed && "rotate-180")} "h-5 w-5 text-sidebar-primary-foreground transition-transform",
!isCollapsed && "rotate-180",
)}
/> />
</button> </button>
</div> </div>
</div> </div>
) );
} }

View File

@ -30,7 +30,7 @@ interface SidebarIncidentsTabProps {
formattedTime: string; formattedTime: string;
location: string; location: string;
selectedMonth?: number | "all"; selectedMonth?: number | "all";
selectedYear: number; selectedYear: number | "all";
selectedCategory: string | "all"; selectedCategory: string | "all";
getTimePeriodDisplay: () => string; getTimePeriodDisplay: () => string;
paginationState: Record<string, number>; paginationState: Record<string, number>;

View File

@ -15,7 +15,7 @@ import { Button } from "@/app/_components/ui/button"
interface ISidebarStatisticsTabProps { interface ISidebarStatisticsTabProps {
crimeStats: ICrimeAnalytics crimeStats: ICrimeAnalytics
selectedMonth?: number | "all" selectedMonth?: number | "all"
selectedYear: number selectedYear: number | "all"
sourceType?: string sourceType?: string
crimes?: ICrimes[] crimes?: ICrimes[]
} }

View File

@ -9,8 +9,8 @@ import { Skeleton } from "../../ui/skeleton"
interface MapSelectorsProps { interface MapSelectorsProps {
availableYears: (number | null)[] availableYears: (number | null)[]
selectedYear: number selectedYear: number | "all"
setSelectedYear: (year: number) => void setSelectedYear: (year: number | "all") => void
selectedMonth: number | "all" selectedMonth: number | "all"
setSelectedMonth: (month: number | "all") => void setSelectedMonth: (month: number | "all") => void
selectedCategory: string | "all" selectedCategory: string | "all"
@ -20,6 +20,7 @@ interface MapSelectorsProps {
isCategoryLoading?: boolean isCategoryLoading?: boolean
className?: string className?: string
compact?: boolean compact?: boolean
disableYearMonth?: boolean
} }
export default function MapSelectors({ export default function MapSelectors({
@ -35,6 +36,7 @@ export default function MapSelectors({
isCategoryLoading = false, isCategoryLoading = false,
className = "", className = "",
compact = false, compact = false,
disableYearMonth = false,
}: MapSelectorsProps) { }: MapSelectorsProps) {
const resetFilters = () => { const resetFilters = () => {
setSelectedYear(2024) setSelectedYear(2024)
@ -50,6 +52,7 @@ export default function MapSelectors({
onYearChange={setSelectedYear} onYearChange={setSelectedYear}
isLoading={isYearsLoading} isLoading={isYearsLoading}
className={compact ? "w-full" : ""} className={compact ? "w-full" : ""}
disabled={disableYearMonth}
/> />
<MonthSelector <MonthSelector
@ -57,6 +60,7 @@ export default function MapSelectors({
onMonthChange={setSelectedMonth} onMonthChange={setSelectedMonth}
isLoading={isYearsLoading} isLoading={isYearsLoading}
className={compact ? "w-full" : ""} className={compact ? "w-full" : ""}
disabled={disableYearMonth}
/> />
<CategorySelector <CategorySelector

View File

@ -26,6 +26,7 @@ interface MonthSelectorProps {
className?: string className?: string
includeAllOption?: boolean includeAllOption?: boolean
isLoading?: boolean isLoading?: boolean
disabled?: boolean
} }
export default function MonthSelector({ export default function MonthSelector({
@ -34,6 +35,7 @@ export default function MonthSelector({
className = "w-[120px]", className = "w-[120px]",
includeAllOption = true, includeAllOption = true,
isLoading = false, isLoading = false,
disabled = false,
}: MonthSelectorProps) { }: MonthSelectorProps) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [isClient, setIsClient] = useState(false) const [isClient, setIsClient] = useState(false)
@ -52,26 +54,27 @@ export default function MonthSelector({
<Skeleton className="h-full w-full rounded-md" /> <Skeleton className="h-full w-full rounded-md" />
</div> </div>
) : ( ) : (
<Select <Select
value={selectedMonth.toString()} value={selectedMonth.toString()}
onValueChange={(value) => onMonthChange(value === "all" ? "all" : Number(value))} onValueChange={(value) => onMonthChange(value === "all" ? "all" : Number(value))}
> disabled={disabled}
<SelectTrigger className={className}>
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent
container={containerRef.current || container || undefined}
style={{ zIndex: 2000 }}
className={`${className}`}
> >
{includeAllOption && <SelectItem value="all">All Months</SelectItem>} <SelectTrigger className={`${className} ${disabled ? 'opacity-60 cursor-not-allowed bg-muted' : ''}`}>
{months.map((month) => ( <SelectValue placeholder="Month" />
<SelectItem key={month.value} value={month.value}> </SelectTrigger>
{month.label} <SelectContent
</SelectItem> container={containerRef.current || container || undefined}
))} style={{ zIndex: 2000 }}
</SelectContent> className={`${className}`}
</Select> >
{includeAllOption && <SelectItem value="all">All Months</SelectItem>}
{months.map((month) => (
<SelectItem key={month.value} value={month.value}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
)} )}
</div> </div>
) )

View File

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

View File

@ -6,9 +6,9 @@ import { AlertTriangle, Building, Car, Thermometer, History } from "lucide-react
import type { ITooltipsControl } from "./tooltips" import type { ITooltipsControl } from "./tooltips"
import { IconChartBubble, IconClock } from "@tabler/icons-react" import { IconChartBubble, IconClock } from "@tabler/icons-react"
// Define the primary crime data controls // Update the tooltip for "incidents" to "All Incidents"
const crimeTooltips = [ const crimeTooltips = [
// { id: "incidents" as ITooltipsControl, icon: <AlertTriangle size={20} />, label: "All Incidents" }, { id: "incidents" as ITooltipsControl, icon: <AlertTriangle size={20} />, label: "All Incidents" },
{ id: "heatmap" as ITooltipsControl, icon: <Thermometer size={20} />, label: "Density Heatmap" }, { id: "heatmap" as ITooltipsControl, icon: <Thermometer size={20} />, label: "Density Heatmap" },
{ id: "units" as ITooltipsControl, icon: <Building size={20} />, label: "Police Units" }, { id: "units" as ITooltipsControl, icon: <Building size={20} />, label: "Police Units" },
{ id: "clusters" as ITooltipsControl, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" }, { id: "clusters" as ITooltipsControl, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" },

View File

@ -11,7 +11,6 @@ import type { ReactNode } from "react"
export type ITooltipsControl = export type ITooltipsControl =
// Crime data views // Crime data views
| "incidents" | "incidents"
| "historical"
| "heatmap" | "heatmap"
| "units" | "units"
| "patrol" | "patrol"
@ -42,8 +41,8 @@ interface TooltipProps {
selectedSourceType: string selectedSourceType: string
setSelectedSourceType: (sourceType: string) => void setSelectedSourceType: (sourceType: string) => 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 selectedYear: number | "all"
setSelectedYear: (year: number) => void setSelectedYear: (year: number | "all") => void
selectedMonth: number | "all" selectedMonth: number | "all"
setSelectedMonth: (month: number | "all") => void setSelectedMonth: (month: number | "all") => void
selectedCategory: string | "all" selectedCategory: string | "all"
@ -51,6 +50,7 @@ interface TooltipProps {
availableYears?: (number | null)[] availableYears?: (number | null)[]
categories?: string[] categories?: string[]
crimes?: any[] // Add this prop to receive crime data crimes?: any[] // Add this prop to receive crime data
disableYearMonth?: boolean
} }
export default function Tooltips({ export default function Tooltips({
@ -68,6 +68,7 @@ export default function Tooltips({
availableYears = [], availableYears = [],
categories = [], categories = [],
crimes = [], crimes = [],
disableYearMonth = false,
}: TooltipProps) { }: TooltipProps) {
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const [isClient, setIsClient] = useState(false) const [isClient, setIsClient] = useState(false)
@ -97,6 +98,7 @@ export default function Tooltips({
setSelectedCategory={setSelectedCategory} setSelectedCategory={setSelectedCategory}
availableYears={availableYears} availableYears={availableYears}
categories={categories} categories={categories}
disableYearMonth={disableYearMonth}
/> />
{/* Search Control Component */} {/* Search Control Component */}

View File

@ -1,24 +1,24 @@
"use client" "use client";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select" import {
import { createRoot } from "react-dom/client" Select,
import { useRef, useEffect, useState } from "react" SelectContent,
import { Skeleton } from "../../ui/skeleton" SelectItem,
SelectTrigger,
interface YearSelectorProps { SelectValue,
availableYears?: (number | null)[] } from "@/app/_components/ui/select";
selectedYear: number import { createRoot } from "react-dom/client";
onYearChange: (year: number) => void import { useEffect, useRef, useState } from "react";
isLoading?: boolean import { Skeleton } from "../../ui/skeleton";
className?: string
}
interface YearSelectorProps { interface YearSelectorProps {
availableYears?: (number | null)[]; availableYears?: (number | null)[];
selectedYear: number; selectedYear: number | "all";
onYearChange: (year: number) => void; onYearChange: (year: number | "all") => void;
includeAllOption?: boolean;
isLoading?: boolean; isLoading?: boolean;
className?: string; className?: string;
disabled?: boolean;
} }
// React component for the year selector UI // React component for the year selector UI
@ -26,8 +26,10 @@ function YearSelectorUI({
availableYears = [], availableYears = [],
selectedYear, selectedYear,
onYearChange, onYearChange,
includeAllOption = true,
isLoading = false, isLoading = false,
className = "w-[120px]" className = "w-[120px]",
disabled = false,
}: YearSelectorProps) { }: YearSelectorProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isClient, setIsClient] = useState(false); const [isClient, setIsClient] = useState(false);
@ -41,36 +43,54 @@ function YearSelectorUI({
const container = isClient ? document.getElementById("root") : null; const container = isClient ? document.getElementById("root") : null;
return ( return (
<div ref={containerRef} className="mapboxgl-year-selector"> <div ref={containerRef} className="mapboxgl-month-selector">
{isLoading ? ( {isLoading
<div className=" h-8"> ? (
<Skeleton className="h-full w-full rounded-md" /> <div className="flex items-center justify-center h-8">
</div> <Skeleton className="h-full w-full rounded-md" />
) : ( </div>
<Select )
value={selectedYear.toString()} : (
onValueChange={(value) => onYearChange(Number(value))} <Select
disabled={isLoading} value={selectedYear.toString()}
> onValueChange={(value) =>
<SelectTrigger className={className}> onYearChange(
<SelectValue placeholder="Year" /> value === "all" ? "all" : Number(value),
</SelectTrigger> )}
{/* Ensure that the dropdown content renders correctly only on the client side */} disabled={disabled}
>
<SelectTrigger
className={`${className} ${
disabled
? "opacity-60 cursor-not-allowed bg-muted"
: ""
}`}
>
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent <SelectContent
container={containerRef.current || container || undefined} container={containerRef.current || container ||
undefined}
style={{ zIndex: 2000 }} style={{ zIndex: 2000 }}
className={`${className}`} className={`${className}`}
> >
{availableYears {includeAllOption && (
?.filter((year) => year !== null) <SelectItem value="all">All Years</SelectItem>
.map((year) => ( )}
<SelectItem key={year} value={year!.toString()}>
{availableYears.map((year) => (
year !== null && (
<SelectItem
key={year}
value={year.toString()}
>
{year} {year}
</SelectItem> </SelectItem>
))} )
))}
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
</div> </div>
); );
} }
@ -88,14 +108,14 @@ export class YearSelectorControl {
onAdd(map: any) { onAdd(map: any) {
this._map = map; this._map = map;
this._container = document.createElement('div'); this._container = document.createElement("div");
this._container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group'; this._container.className = "mapboxgl-ctrl mapboxgl-ctrl-group";
this._container.style.padding = '5px'; this._container.style.padding = "5px";
// Set position to relative to keep dropdown content in context // Set position to relative to keep dropdown content in context
this._container.style.position = 'relative'; this._container.style.position = "relative";
// Higher z-index to ensure dropdown appears above map elements // Higher z-index to ensure dropdown appears above map elements
this._container.style.zIndex = '50'; this._container.style.zIndex = "50";
// Create React root for rendering our component // Create React root for rendering our component
this._root = createRoot(this._container); this._root = createRoot(this._container);
@ -123,4 +143,4 @@ export default function YearSelector(props: YearSelectorProps) {
// This wrapper allows the component to be used both as a React component // This wrapper allows the component to be used both as a React component
// and to help create a MapboxGL control // and to help create a MapboxGL control
return <YearSelectorUI {...props} />; return <YearSelectorUI {...props} />;
} }

View File

@ -1,96 +1,143 @@
"use client" "use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card" import {
import { Skeleton } from "@/app/_components/ui/skeleton" Card,
import MapView from "./map" CardContent,
import { Button } from "@/app/_components/ui/button" CardHeader,
import { AlertCircle } from "lucide-react" CardTitle,
import { getMonthName } from "@/app/_utils/common" } from "@/app/_components/ui/card";
import { useRef, useState, useCallback, useMemo, useEffect } from "react" import { Skeleton } from "@/app/_components/ui/skeleton";
import { useFullscreen } from "@/app/_hooks/use-fullscreen" import MapView from "./map";
import { Overlay } from "./overlay" import { Button } from "@/app/_components/ui/button";
import MapLegend from "./legends/map-legend" import { AlertCircle } from "lucide-react";
import UnitsLegend from "./legends/units-legend" import { getMonthName } from "@/app/_utils/common";
import TimelineLegend from "./legends/timeline-legend" import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes, useGetCrimeTypes, useGetRecentIncidents } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries" import { useFullscreen } from "@/app/_hooks/use-fullscreen";
import MapSelectors from "./controls/map-selector" import { Overlay } from "./overlay";
import MapLegend from "./legends/map-legend";
import UnitsLegend from "./legends/units-legend";
import TimelineLegend from "./legends/timeline-legend";
import {
useGetAvailableYears,
useGetCrimeCategories,
useGetCrimes,
useGetCrimeTypes,
useGetRecentIncidents,
} from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries";
import MapSelectors from "./controls/map-selector";
import { cn } from "@/app/_lib/utils" import { cn } from "@/app/_lib/utils";
import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client" import {
import { CrimeTimelapse } from "./controls/bottom/crime-timelapse" $Enums,
import { ITooltipsControl } from "./controls/top/tooltips" crime_categories,
import CrimeSidebar from "./controls/left/sidebar/map-sidebar" crime_incidents,
import Tooltips from "./controls/top/tooltips" crimes,
import Layers from "./layers/layers" demographics,
districts,
geographics,
locations,
} from "@prisma/client";
import { CrimeTimelapse } from "./controls/bottom/crime-timelapse";
import { ITooltipsControl } from "./controls/top/tooltips";
import CrimeSidebar from "./controls/left/sidebar/map-sidebar";
import Tooltips from "./controls/top/tooltips";
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 { 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 { addMockIncident, getAllIncidents, resolveIncident } from "@/app/_utils/mock/ews-data" import {
import { useMap } from "react-map-gl/mapbox" addMockIncident,
import PanicButtonDemo from "./controls/panic-button-demo" getAllIncidents,
resolveIncident,
} from "@/app/_utils/mock/ews-data";
import { useMap } from "react-map-gl/mapbox";
import PanicButtonDemo from "./controls/panic-button-demo";
export default function CrimeMap() { export default function CrimeMap() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(true) const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const [selectedDistrict, setSelectedDistrict] = useState<IDistrictFeature | null>(null) const [activeControl, setActiveControl] = useState<ITooltipsControl>(
const [showLegend, setShowLegend] = useState<boolean>(true) "clusters",
const [activeControl, setActiveControl] = useState<ITooltipsControl>("clusters") );
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu") const [selectedDistrict, setSelectedDistrict] = useState<
const [selectedYear, setSelectedYear] = useState<number>(2024) IDistrictFeature | null
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all") >(null);
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all") const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu");
const [yearProgress, setYearProgress] = useState(0) const [selectedYear, setSelectedYear] = useState<number | "all">(2024);
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false) const [selectedMonth, setSelectedMonth] = useState<number | "all">("all");
const [isSearchActive, setIsSearchActive] = useState(false) const [selectedCategory, setSelectedCategory] = useState<string | "all">(
const [showUnitsLayer, setShowUnitsLayer] = useState(false) "all",
const [showClusters, setShowClusters] = useState(false) );
const [showHeatmap, setShowHeatmap] = useState(false) const [ewsIncidents, setEwsIncidents] = useState<IIncidentLog[]>([]);
const [showUnclustered, setShowUnclustered] = useState(true)
const [useAllYears, setUseAllYears] = useState<boolean>(false)
const [useAllMonths, setUseAllMonths] = useState<boolean>(false)
const [showEWS, setShowEWS] = useState<boolean>(true)
const [ewsIncidents, setEwsIncidents] = useState<IIncidentLog[]>([])
const [showPanicDemo, setShowPanicDemo] = useState(true)
const [displayPanicDemo, setDisplayPanicDemo] = useState(showEWS && showPanicDemo)
const mapContainerRef = useRef<HTMLDivElement>(null) const [useAllYears, setUseAllYears] = useState<boolean>(false);
const [useAllMonths, setUseAllMonths] = useState<boolean>(false);
const { current: mapInstance } = useMap() const [showAllIncidents, setShowAllIncidents] = useState(false);
const [showLegend, setShowLegend] = useState<boolean>(true);
const [showUnitsLayer, setShowUnitsLayer] = useState(false);
const [showClusters, setShowClusters] = useState(false);
const [showHeatmap, setShowHeatmap] = useState(false);
const [showTimelineLayer, setShowTimelineLayer] = useState(false);
const [showEWS, setShowEWS] = useState<boolean>(true);
const [showPanicDemo, setShowPanicDemo] = useState(true);
const [displayPanicDemo, setDisplayPanicDemo] = useState(
showEWS && showPanicDemo,
);
const mapboxMap = mapInstance?.getMap() || null const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false);
const [yearProgress, setYearProgress] = useState(0);
const [isSearchActive, setIsSearchActive] = useState(false);
const { isFullscreen } = useFullscreen(mapContainerRef) const mapContainerRef = useRef<HTMLDivElement>(null);
const { data: availableSourceTypes, isLoading: isTypeLoading } = useGetCrimeTypes() const { current: mapInstance } = useMap();
const mapboxMap = mapInstance?.getMap() || null;
const { isFullscreen } = useFullscreen(mapContainerRef);
const { data: availableSourceTypes, isLoading: isTypeLoading } =
useGetCrimeTypes();
const { const {
data: availableYears, data: availableYears,
isLoading: isYearsLoading, isLoading: isYearsLoading,
error: yearsError error: yearsError,
} = useGetAvailableYears() } = useGetAvailableYears();
const { data: categoriesData, isLoading: isCategoryLoading } = useGetCrimeCategories() const { data: categoriesData, isLoading: isCategoryLoading } =
useGetCrimeCategories();
const categories = useMemo(() => const categories = useMemo(
categoriesData ? categoriesData.map(category => category.name) : [] () =>
, [categoriesData]) categoriesData
? categoriesData.map((category) => category.name)
: [],
[categoriesData],
);
const { const {
data: crimes, data: crimes,
isLoading: isCrimesLoading, isLoading: isCrimesLoading,
error: crimesError error: crimesError,
} = useGetCrimes() } = useGetCrimes();
const { data: fetchedUnits, isLoading } = useGetUnitsQuery() const { data: fetchedUnits, isLoading } = useGetUnitsQuery();
const { data: recentIncidents } = useGetRecentIncidents() const { data: recentIncidents } = useGetRecentIncidents();
useEffect(() => { useEffect(() => {
if (activeControl === "heatmap" || activeControl === "timeline") { if (
activeControl === "heatmap" || activeControl === "timeline" ||
activeControl === "incidents"
) {
setSelectedYear("all");
setUseAllYears(true); setUseAllYears(true);
setUseAllMonths(true); setUseAllMonths(true);
} else { } else {
setSelectedYear(2024);
setUseAllYears(false); setUseAllYears(false);
setUseAllMonths(false); setUseAllMonths(false);
} }
@ -98,7 +145,9 @@ export default function CrimeMap() {
const crimesBySourceType = useMemo(() => { const crimesBySourceType = useMemo(() => {
if (!crimes) return []; if (!crimes) return [];
return crimes.filter(crime => crime.source_type === selectedSourceType); return crimes.filter((crime) =>
crime.source_type === selectedSourceType
);
}, [crimes, selectedSourceType]); }, [crimes, selectedSourceType]);
const filteredByYearAndMonth = useMemo(() => { const filteredByYearAndMonth = useMemo(() => {
@ -109,7 +158,9 @@ export default function CrimeMap() {
return crimesBySourceType; return crimesBySourceType;
} else { } else {
return crimesBySourceType.filter((crime) => { return crimesBySourceType.filter((crime) => {
return selectedMonth === "all" ? true : crime.month === selectedMonth; return selectedMonth === "all"
? true
: crime.month === selectedMonth;
}); });
} }
} }
@ -123,59 +174,70 @@ export default function CrimeMap() {
return yearMatch && crime.month === selectedMonth; return yearMatch && crime.month === selectedMonth;
} }
}); });
}, [crimesBySourceType, selectedYear, selectedMonth, useAllYears, useAllMonths]); }, [
crimesBySourceType,
selectedYear,
selectedMonth,
useAllYears,
useAllMonths,
]);
const filteredCrimes = useMemo(() => { const filteredCrimes = useMemo(() => {
if (!filteredByYearAndMonth) return [] if (!filteredByYearAndMonth) return [];
if (selectedCategory === "all") return filteredByYearAndMonth if (selectedCategory === "all") return filteredByYearAndMonth;
return filteredByYearAndMonth.map((crime) => { return filteredByYearAndMonth.map((crime) => {
const filteredIncidents = crime.crime_incidents.filter( const filteredIncidents = crime.crime_incidents.filter(
incident => incident.crime_categories.name === selectedCategory (incident) =>
) incident.crime_categories.name === selectedCategory,
);
return { return {
...crime, ...crime,
crime_incidents: filteredIncidents, crime_incidents: filteredIncidents,
number_of_crime: filteredIncidents.length number_of_crime: filteredIncidents.length,
} };
}) });
}, [filteredByYearAndMonth, selectedCategory]) }, [filteredByYearAndMonth, selectedCategory]);
useEffect(() => { useEffect(() => {
if (selectedSourceType === "cbu") { if (selectedSourceType === "cbu") {
if (activeControl !== "clusters" && activeControl !== "reports" && if (
activeControl !== "clusters" && activeControl !== "reports" &&
activeControl !== "layers" && activeControl !== "search" && activeControl !== "layers" && activeControl !== "search" &&
activeControl !== "alerts") { activeControl !== "alerts"
) {
setActiveControl("clusters"); setActiveControl("clusters");
setShowClusters(true); setShowClusters(true);
setShowUnclustered(false);
} }
} }
}, [selectedSourceType, activeControl]); }, [selectedSourceType, activeControl]);
useEffect(() => { useEffect(() => {
setEwsIncidents(getAllIncidents()) setEwsIncidents(getAllIncidents());
}, []) }, []);
const handleTriggerAlert = useCallback((priority: "high" | "medium" | "low") => { const handleTriggerAlert = useCallback(
const newIncident = addMockIncident({ priority }) (priority: "high" | "medium" | "low") => {
setEwsIncidents(getAllIncidents()) const newIncident = addMockIncident({ priority });
}, []) setEwsIncidents(getAllIncidents());
},
[],
);
const handleResolveIncident = useCallback((id: string) => { const handleResolveIncident = useCallback((id: string) => {
resolveIncident(id) resolveIncident(id);
setEwsIncidents(getAllIncidents()) setEwsIncidents(getAllIncidents());
}, []) }, []);
const handleResolveAllAlerts = useCallback(() => { const handleResolveAllAlerts = useCallback(() => {
ewsIncidents.forEach((incident) => { ewsIncidents.forEach((incident) => {
if (incident.status === "active") { if (incident.status === "active") {
resolveIncident(incident.id) resolveIncident(incident.id);
} }
}) });
setEwsIncidents(getAllIncidents()) setEwsIncidents(getAllIncidents());
}, [ewsIncidents]) }, [ewsIncidents]);
const handleSourceTypeChange = useCallback((sourceType: string) => { const handleSourceTypeChange = useCallback((sourceType: string) => {
setSelectedSourceType(sourceType); setSelectedSourceType(sourceType);
@ -183,37 +245,40 @@ export default function CrimeMap() {
if (sourceType === "cbu") { if (sourceType === "cbu") {
setActiveControl("clusters"); setActiveControl("clusters");
setShowClusters(true); setShowClusters(true);
setShowUnclustered(false);
} else { } else {
setActiveControl("clusters"); setActiveControl("clusters");
setShowUnclustered(true);
setShowClusters(false); setShowClusters(false);
} }
}, []); }, []);
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => { const handleTimelineChange = useCallback(
setSelectedYear(year) (year: number, month: number, progress: number) => {
setSelectedMonth(month) setSelectedYear(year);
setYearProgress(progress) setSelectedMonth(month);
}, []) setYearProgress(progress);
},
[],
);
const handleTimelinePlayingChange = useCallback((playing: boolean) => { const handleTimelinePlayingChange = useCallback((playing: boolean) => {
setisTimelapsePlaying(playing) setisTimelapsePlaying(playing);
if (playing) { if (playing) {
setSelectedDistrict(null) setSelectedDistrict(null);
} }
}, []) }, []);
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
setSelectedYear(2024) setSelectedYear(2024);
setSelectedMonth("all") setSelectedMonth("all");
setSelectedCategory("all") setSelectedCategory("all");
}, []) }, []);
const getMapTitle = () => { const getMapTitle = () => {
if (useAllYears) { if (useAllYears) {
return `All Years Data ${selectedCategory !== "all" ? `- ${selectedCategory}` : ''}`; return `All Years Data ${
selectedCategory !== "all" ? `- ${selectedCategory}` : ""
}`;
} }
let title = `${selectedYear}`; let title = `${selectedYear}`;
@ -224,39 +289,52 @@ export default function CrimeMap() {
title += ` - ${selectedCategory}`; title += ` - ${selectedCategory}`;
} }
return title; return title;
} };
const handleControlChange = (controlId: ITooltipsControl) => { const handleControlChange = (controlId: ITooltipsControl) => {
if (selectedSourceType === "cbu" && if (
!["clusters", "reports", "layers", "search", "alerts"].includes(controlId as string)) { selectedSourceType === "cbu" &&
!["clusters", "reports", "layers", "search", "alerts"].includes(
controlId as string,
)
) {
return; return;
} }
setActiveControl(controlId); setActiveControl(controlId);
if (controlId === "clusters") { if (controlId === "clusters") {
setShowClusters(true) setShowClusters(true);
} else { } else {
setShowClusters(false) setShowClusters(false);
} }
if (controlId === "incidents") { if (controlId === "incidents") {
setShowUnclustered(true) setShowAllIncidents(true);
} else { } else {
setShowUnclustered(false) setShowAllIncidents(false);
} }
if (controlId === "search") { if (controlId === "search") {
setIsSearchActive(prev => !prev); setIsSearchActive((prev) => !prev);
} }
if (controlId === "units") { if (controlId === "units") {
setShowUnitsLayer(true); setShowUnitsLayer(true);
} else if (showUnitsLayer) { } else {
setShowUnitsLayer(false); setShowUnitsLayer(false);
} }
if (controlId === "heatmap" || controlId === "timeline") { if (controlId === "timeline") {
setShowTimelineLayer(true);
} else {
setShowTimelineLayer(false);
}
if (
controlId === "heatmap" || controlId === "timeline" ||
controlId === "incidents"
) {
setUseAllYears(true); setUseAllYears(true);
setUseAllMonths(true); setUseAllMonths(true);
} else { } else {
@ -265,9 +343,9 @@ export default function CrimeMap() {
} }
setShowEWS(true); setShowEWS(true);
} };
const showTimelineLayer = activeControl === "timeline"; // const showTimelineLayer = activeControl === "timeline";
return ( return (
<Card className="w-full p-0 border-none shadow-none h-96"> <Card className="w-full p-0 border-none shadow-none h-96">
@ -284,128 +362,164 @@ export default function CrimeMap() {
categories={categories} categories={categories}
isYearsLoading={isYearsLoading} isYearsLoading={isYearsLoading}
isCategoryLoading={isCategoryLoading} isCategoryLoading={isCategoryLoading}
disableYearMonth={activeControl === "incidents" ||
activeControl === "heatmap" ||
activeControl === "timeline"}
/> />
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
{isCrimesLoading ? ( {isCrimesLoading
<div className="flex items-center justify-center h-96"> ? (
<Skeleton className="h-full w-full rounded-md" /> <div className="flex items-center justify-center h-96">
</div> <Skeleton className="h-full w-full rounded-md" />
) : crimesError ? ( </div>
<div className="flex flex-col items-center justify-center h-96 gap-4"> )
<AlertCircle className="h-10 w-10 text-destructive" /> : crimesError
<p className="text-center">Failed to load crime data. Please try again later.</p> ? (
<Button onClick={() => window.location.reload()}>Retry</Button> <div className="flex flex-col items-center justify-center h-96 gap-4">
</div> <AlertCircle className="h-10 w-10 text-destructive" />
) : ( <p className="text-center">
<div className="mapbox-container overlay-bg relative h-[600px]" ref={mapContainerRef}> Failed to load crime data. Please try again
<div className={cn( later.
</p>
<Button onClick={() => window.location.reload()}>
Retry
</Button>
</div>
)
: (
<div
className="mapbox-container overlay-bg relative h-[600px]"
ref={mapContainerRef}
>
<div
className={cn(
"transition-all duration-300 ease-in-out", "transition-all duration-300 ease-in-out",
!sidebarCollapsed && isFullscreen && "ml-[400px]" !sidebarCollapsed && isFullscreen &&
)}> "ml-[400px]",
<div className=""> )}
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md"> >
<Layers <div className="">
crimes={filteredCrimes || []} <MapView
units={fetchedUnits || []} mapStyle="mapbox://styles/mapbox/dark-v11"
year={selectedYear.toString()} className="h-[600px] w-full rounded-md"
month={selectedMonth.toString()} >
filterCategory={selectedCategory} <Layers
activeControl={activeControl} crimes={filteredCrimes || []}
useAllData={useAllYears} units={fetchedUnits || []}
showEWS={showEWS} year={selectedYear.toString()}
recentIncidents={recentIncidents || []} month={selectedMonth.toString()}
sourceType={selectedSourceType} filterCategory={selectedCategory}
/> activeControl={activeControl}
useAllData={useAllYears}
showEWS={showEWS}
recentIncidents={recentIncidents ||
[]}
sourceType={selectedSourceType}
/>
{isFullscreen && (
<>
<div className="absolute flex w-full p-2">
<Tooltips
activeControl={activeControl}
onControlChange={handleControlChange}
selectedSourceType={selectedSourceType}
setSelectedSourceType={handleSourceTypeChange}
availableSourceTypes={availableSourceTypes ||
[]}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
selectedMonth={selectedMonth}
setSelectedMonth={setSelectedMonth}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
availableYears={availableYears ||
[]}
categories={categories}
crimes={filteredCrimes}
disableYearMonth={activeControl ===
"incidents" ||
activeControl ===
"heatmap" ||
activeControl ===
"timeline"}
/>
</div>
{isFullscreen && ( {mapboxMap && (
<> <EWSAlertLayer
<div className="absolute flex w-full p-2"> map={mapboxMap}
<Tooltips incidents={ewsIncidents}
activeControl={activeControl} onIncidentResolved={handleResolveIncident}
onControlChange={handleControlChange} />
selectedSourceType={selectedSourceType} )}
setSelectedSourceType={handleSourceTypeChange}
availableSourceTypes={availableSourceTypes || []} {displayPanicDemo && (
selectedYear={selectedYear} <div className="absolute top-0 right-20 z-50 p-2">
setSelectedYear={setSelectedYear} <PanicButtonDemo
selectedMonth={selectedMonth} onTriggerAlert={handleTriggerAlert}
setSelectedMonth={setSelectedMonth} onResolveAllAlerts={handleResolveAllAlerts}
selectedCategory={selectedCategory} activeIncidents={ewsIncidents
setSelectedCategory={setSelectedCategory} .filter((inc) =>
availableYears={availableYears || []} inc.status ===
categories={categories} "active"
crimes={filteredCrimes} )}
/> />
</div> </div>
)}
{mapboxMap && ( <CrimeSidebar
<EWSAlertLayer map={mapboxMap} incidents={ewsIncidents} onIncidentResolved={handleResolveIncident} /> crimes={filteredCrimes ||[]}
)} defaultCollapsed={sidebarCollapsed}
{displayPanicDemo && ( selectedCategory={selectedCategory}
<div className="absolute top-0 right-20 z-50 p-2"> selectedYear={selectedYear}
<PanicButtonDemo selectedMonth={selectedMonth}
onTriggerAlert={handleTriggerAlert} sourceType={selectedSourceType} // Pass the sourceType
onResolveAllAlerts={handleResolveAllAlerts}
activeIncidents={ewsIncidents.filter((inc) => inc.status === "active")}
/>
</div>
)}
<CrimeSidebar
crimes={filteredCrimes || []}
defaultCollapsed={sidebarCollapsed}
selectedCategory={selectedCategory}
selectedYear={selectedYear}
selectedMonth={selectedMonth}
sourceType={selectedSourceType} // Pass the sourceType
/>
<div className="absolute bottom-20 right-0 z-20 p-2">
{showClusters && (
<MapLegend position="bottom-right" />
)}
{showUnclustered && !showClusters && (
<MapLegend position="bottom-right" />
)}
</div>
{showUnitsLayer && (
<div className="absolute bottom-20 right-0 z-10 p-2">
<UnitsLegend
categories={categories}
position="bottom-right"
/>
</div>
)}
{showTimelineLayer && (
<div className="absolute flex bottom-20 right-0 z-10 p-2">
<TimelineLegend position="bottom-right" />
</div>
)}
</>
)}
<div className="absolute flex w-full bottom-0">
<CrimeTimelapse
startYear={2020}
endYear={2024}
autoPlay={false}
onChange={handleTimelineChange}
onPlayingChange={handleTimelinePlayingChange}
/> />
</div>
</MapView> <div className="absolute bottom-20 right-0 z-20 p-2">
</div> {showClusters && (
<MapLegend position="bottom-right" />
)}
{!showClusters && (
<MapLegend position="bottom-right" />
)}
</div>
{showUnitsLayer && (
<div className="absolute bottom-20 right-0 z-10 p-2">
<UnitsLegend
categories={categories}
position="bottom-right"
/>
</div>
)}
{showTimelineLayer && (
<div className="absolute flex bottom-20 right-0 z-10 p-2">
<TimelineLegend position="bottom-right" />
</div>
)}
</>
)}
<div className="absolute flex w-full bottom-0">
<CrimeTimelapse
startYear={2020}
endYear={2024}
autoPlay={false}
onChange={handleTimelineChange}
onPlayingChange={handleTimelinePlayingChange}
/>
</div>
</MapView>
</div> </div>
</div> </div>
)} </div>
)}
</CardContent> </CardContent>
</Card> </Card>
) );
} }

View File

@ -0,0 +1,429 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ICrimes } from "@/app/_utils/types/crimes";
import {
BASE_BEARING,
BASE_DURATION,
BASE_PITCH,
BASE_ZOOM,
PITCH_3D,
ZOOM_3D,
} from "@/app/_utils/const/map";
import IncidentPopup from "../pop-up/incident-popup";
import type mapboxgl from "mapbox-gl";
import type { MapGeoJSONFeature, MapMouseEvent } from "react-map-gl/mapbox";
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility";
interface IAllIncidentsLayerProps {
visible?: boolean;
map: mapboxgl.Map | null;
crimes: ICrimes[];
filterCategory: string | "all";
}
interface IIncidentFeatureProperties {
id: string;
category: string;
description: string;
timestamp: string;
district: string;
district_id: string;
year: number;
month: number;
address: string | null;
latitude: number;
longitude: number;
}
interface IIncidentDetails {
id: string;
category: string;
description: string;
timestamp: Date;
district: string;
district_id: string;
year: number;
month: number;
address: string | null;
latitude: number;
longitude: number;
}
export default function AllIncidentsLayer(
{ visible = false, map, crimes = [], filterCategory = "all" }:
IAllIncidentsLayerProps,
) {
const isInteractingWithMarker = useRef(false);
const animationFrameRef = useRef<number | null>(null);
const [selectedIncident, setSelectedIncident] = useState<
IIncidentDetails | null
>(null);
// Define layer IDs for consistent management
const LAYER_IDS = [
"all-incidents-pulse",
"all-incidents-circles",
"all-incidents",
];
const handleIncidentClick = useCallback(
(e: MapMouseEvent & { features?: MapGeoJSONFeature[] }) => {
if (!map) return;
const features = map.queryRenderedFeatures(e.point, {
layers: ["all-incidents"],
});
if (!features || features.length === 0) return;
// Stop event propagation
e.originalEvent.stopPropagation();
e.preventDefault();
isInteractingWithMarker.current = true;
const incident = features[0];
if (!incident.properties) return;
const props = incident
.properties as unknown as IIncidentFeatureProperties;
const IincidentDetails: IIncidentDetails = {
id: props.id,
description: props.description,
category: props.category,
district: props.district,
district_id: props.district_id,
year: props.year,
month: props.month,
address: props.address,
latitude: props.latitude,
longitude: props.longitude,
timestamp: new Date(props.timestamp || Date.now()),
};
// Fly to the incident location
map.flyTo({
center: [IincidentDetails.longitude, IincidentDetails.latitude],
zoom: ZOOM_3D,
bearing: BASE_BEARING,
pitch: PITCH_3D,
duration: BASE_DURATION,
});
// Set selected incident for the popup
setSelectedIncident(IincidentDetails);
// Reset the flag after a delay
setTimeout(() => {
isInteractingWithMarker.current = false;
}, 1000);
},
[map],
);
// Handle popup close
const handleClosePopup = useCallback(() => {
if (!map) return;
map.easeTo({
zoom: BASE_ZOOM,
bearing: BASE_BEARING,
pitch: BASE_PITCH,
duration: BASE_DURATION,
});
setSelectedIncident(null);
}, [map]);
// Effect to manage layer visibility consistently
useEffect(() => {
const cleanup = manageLayerVisibility(map, LAYER_IDS, visible, () => {
// When layers become invisible, close any open popup
if (!visible) setSelectedIncident(null);
// Cancel animation frame when hiding the layer
if (!visible && animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
});
return cleanup;
}, [visible, map]);
useEffect(() => {
if (!map || !visible) return;
// Convert incidents to GeoJSON format
const allIncidents = crimes.flatMap((crime) => {
return crime.crime_incidents
.filter((incident) =>
// Apply category filter if specified
(filterCategory === "all" ||
incident.crime_categories?.name === filterCategory) &&
// Make sure we have valid location data
incident.locations?.latitude &&
incident.locations?.longitude
)
.map((incident) => ({
type: "Feature" as const,
geometry: {
type: "Point" as const,
coordinates: [
incident.locations!.longitude,
incident.locations!.latitude,
],
},
properties: {
id: incident.id,
description: incident.description || "No description",
timestamp: incident.timestamp?.toString() ||
new Date().toString(),
category: incident.crime_categories?.name || "Unknown",
district: crime.districts?.name || "Unknown",
district_id: crime.district_id,
year: crime.year,
month: crime.month,
address: incident.locations?.address || null,
latitude: incident.locations!.latitude,
longitude: incident.locations!.longitude,
},
}));
});
const incidentsGeoJSON = {
type: "FeatureCollection" as const,
features: allIncidents,
};
const setupLayersAndSources = () => {
try {
// Check if source already exists and update it
if (map.getSource("all-incidents-source")) {
const source = map.getSource(
"all-incidents-source",
) as mapboxgl.GeoJSONSource;
source.setData(incidentsGeoJSON);
} else {
// Add source if it doesn't exist
map.addSource("all-incidents-source", {
type: "geojson",
data: incidentsGeoJSON,
});
// Get first symbol layer for insertion order
const layers = map.getStyle().layers;
let firstSymbolId: string | undefined;
for (const layer of layers) {
if (layer.type === "symbol") {
firstSymbolId = layer.id;
break;
}
}
// Pulsing circle effect for very recent incidents
map.addLayer({
id: "all-incidents-pulse",
type: "circle",
source: "all-incidents-source",
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
10,
10,
15,
20,
],
"circle-color": [
"match",
["get", "category"],
"Theft",
"#FF5733",
"Assault",
"#C70039",
"Robbery",
"#900C3F",
"Burglary",
"#581845",
"Fraud",
"#FFC300",
"Homicide",
"#FF0000",
// Default color for other categories
"#2874A6",
],
"circle-opacity": 0.4,
"circle-blur": 0.6,
},
}, firstSymbolId);
// Background circle for all incidents
map.addLayer({
id: "all-incidents-circles",
type: "circle",
source: "all-incidents-source",
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
10,
5,
15,
10,
],
"circle-color": [
"match",
["get", "category"],
"Theft",
"#FF5733",
"Assault",
"#C70039",
"Robbery",
"#900C3F",
"Burglary",
"#581845",
"Fraud",
"#FFC300",
"Homicide",
"#FF0000",
// Default color for other categories
"#2874A6",
],
"circle-stroke-width": 1,
"circle-stroke-color": "#FFFFFF",
"circle-opacity": 0.6,
},
}, firstSymbolId);
// Main incident point
map.addLayer({
id: "all-incidents",
type: "circle",
source: "all-incidents-source",
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
10,
3,
15,
6,
],
"circle-color": [
"match",
["get", "category"],
"Theft",
"#FF5733",
"Assault",
"#C70039",
"Robbery",
"#900C3F",
"Burglary",
"#581845",
"Fraud",
"#FFC300",
"Homicide",
"#FF0000",
// Default color for other categories
"#2874A6",
],
"circle-stroke-width": 1,
"circle-stroke-color": "#FFFFFF",
"circle-opacity": 1,
},
}, firstSymbolId);
}
// Add mouse events
map.on("mouseenter", "all-incidents", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "all-incidents", () => {
map.getCanvas().style.cursor = "";
});
map.on("click", "all-incidents", handleIncidentClick);
} catch (error) {
console.error("Error setting up all incidents layer:", error);
}
};
// Set up layers when the map is ready
if (map.isStyleLoaded()) {
setupLayersAndSources();
} else {
map.once("load", setupLayersAndSources);
}
// Start the pulse animation effect
const animatePulse = () => {
if (!map || !visible || !map.getLayer("all-incidents-pulse")) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
return;
}
const pulseSize = 10 + 5 * Math.sin(Date.now() / 500);
try {
map.setPaintProperty("all-incidents-pulse", "circle-radius", [
"interpolate",
["linear"],
["zoom"],
10,
pulseSize,
15,
pulseSize * 2,
]);
animationFrameRef.current = requestAnimationFrame(animatePulse);
} catch (error) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
}
};
animationFrameRef.current = requestAnimationFrame(animatePulse);
// Clean up event listeners and animation
return () => {
if (map) {
map.off("click", "all-incidents", handleIncidentClick);
map.off("mouseenter", "all-incidents", () => {
map.getCanvas().style.cursor = "pointer";
});
map.off("mouseleave", "all-incidents", () => {
map.getCanvas().style.cursor = "";
});
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
}, [map, visible, crimes, filterCategory, handleIncidentClick]);
return (
<>
{selectedIncident && (
<IncidentPopup
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={handleClosePopup}
incident={selectedIncident}
/>
)}
</>
);
}

View File

@ -1,260 +0,0 @@
"use client"
import { useEffect, useCallback, useRef, useState } from "react"
import type { ICrimes } from "@/app/_utils/types/crimes"
interface HistoricalIncidentsLayerProps {
visible?: boolean
map: any
crimes?: ICrimes[]
filterCategory?: string | "all"
focusedDistrictId?: string | null
}
export default function HistoricalIncidentsLayer({
visible = false,
map,
crimes = [],
filterCategory = "all",
focusedDistrictId,
}: HistoricalIncidentsLayerProps) {
const isInteractingWithMarker = useRef(false);
const currentYear = new Date().getFullYear();
const startYear = 2020;
const [yearColors, setYearColors] = useState<Record<number, string>>({});
// Generate colors for each year from 2020 to current year
useEffect(() => {
const colors: Record<number, string> = {};
const yearCount = currentYear - startYear + 1;
for (let i = 0; i < yearCount; i++) {
const year = startYear + i;
// Generate a color gradient from red (2020) to blue (current year)
const red = Math.floor(255 - (i * 255 / yearCount));
const blue = Math.floor(i * 255 / yearCount);
colors[year] = `rgb(${red}, 70, ${blue})`;
}
setYearColors(colors);
}, [currentYear]);
const handleIncidentClick = useCallback(
(e: any) => {
if (!map) return;
const features = map.queryRenderedFeatures(e.point, { layers: ["historical-incidents"] });
if (!features || features.length === 0) return;
isInteractingWithMarker.current = true;
const incident = features[0];
if (!incident.properties) return;
e.originalEvent.stopPropagation();
e.preventDefault();
const incidentDetails = {
id: incident.properties.id,
district: incident.properties.district,
category: incident.properties.category,
type: incident.properties.incidentType,
description: incident.properties.description,
status: incident.properties?.status || "Unknown",
longitude: (incident.geometry as any).coordinates[0],
latitude: (incident.geometry as any).coordinates[1],
timestamp: new Date(incident.properties.timestamp || Date.now()),
year: incident.properties.year,
};
// console.log("Historical incident clicked:", incidentDetails);
// Ensure markers stay visible when clicking on them
if (map.getLayer("historical-incidents")) {
map.setLayoutProperty("historical-incidents", "visibility", "visible");
}
// First fly to the incident location
map.flyTo({
center: [incidentDetails.longitude, incidentDetails.latitude],
zoom: 15,
bearing: 0,
pitch: 45,
duration: 2000,
});
// Then dispatch the incident_click event to show the popup
const customEvent = new CustomEvent("incident_click", {
detail: incidentDetails,
bubbles: true,
});
map.getCanvas().dispatchEvent(customEvent);
document.dispatchEvent(customEvent);
// Reset the flag after a delay to allow the event to process
setTimeout(() => {
isInteractingWithMarker.current = false;
}, 5000);
},
[map]
);
useEffect(() => {
if (!map || !visible) return;
// console.log("Setting up historical incidents layer");
// Filter incidents from 2020 to current year
const historicalData = {
type: "FeatureCollection" as const,
features: crimes.flatMap((crime) =>
crime.crime_incidents
.filter(
(incident) => {
const incidentYear = incident.timestamp ? new Date(incident.timestamp).getFullYear() : null;
return (
(filterCategory === "all" || incident.crime_categories.name === filterCategory) &&
incident.locations &&
typeof incident.locations.longitude === "number" &&
typeof incident.locations.latitude === "number" &&
incidentYear !== null &&
incidentYear >= startYear &&
incidentYear <= currentYear
);
}
)
.map((incident) => {
const incidentYear = incident.timestamp ? new Date(incident.timestamp).getFullYear() : currentYear;
return {
type: "Feature" as const,
geometry: {
type: "Point" as const,
coordinates: [incident.locations.longitude, incident.locations.latitude],
},
properties: {
id: incident.id,
district: crime.districts.name,
district_id: crime.district_id,
category: incident.crime_categories.name,
incidentType: incident.crime_categories.type || "",
description: incident.description,
status: incident.status || "",
timestamp: incident.timestamp ? incident.timestamp.toString() : "",
year: incidentYear,
},
};
})
),
};
// console.log(`Found ${historicalData.features.length} historical incidents from 2020 to ${currentYear}`);
const setupLayerAndSource = () => {
try {
// Check if source exists and update it
if (map.getSource("historical-incidents-source")) {
(map.getSource("historical-incidents-source") as any).setData(historicalData);
} else {
// If not, add source
map.addSource("historical-incidents-source", {
type: "geojson",
data: historicalData,
// No clustering configuration
});
}
// Find first symbol layer for proper layering
const layers = map.getStyle().layers;
let firstSymbolId: string | undefined;
for (const layer of layers) {
if (layer.type === "symbol") {
firstSymbolId = layer.id;
break;
}
}
// Style for year-based coloring
const circleColorExpression: any[] = [
"match",
["get", "year"],
];
// Add entries for each year and its color
Object.entries(yearColors).forEach(([year, color]) => {
circleColorExpression.push(parseInt(year), color);
});
// Default color for unknown years
circleColorExpression.push("#888888");
// Check if layer exists already
if (!map.getLayer("historical-incidents")) {
map.addLayer({
id: "historical-incidents",
type: "circle",
source: "historical-incidents-source",
paint: {
"circle-color": circleColorExpression,
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
7, 2, // Smaller circles at lower zoom levels
12, 4,
15, 6, // Smaller maximum size
],
"circle-stroke-width": 1,
"circle-stroke-color": "#ffffff",
"circle-opacity": 0.8,
},
layout: {
visibility: visible ? "visible" : "none",
}
}, firstSymbolId);
// Add mouse events
map.on("mouseenter", "historical-incidents", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "historical-incidents", () => {
map.getCanvas().style.cursor = "";
});
} else {
// Update existing layer visibility
map.setLayoutProperty("historical-incidents", "visibility", visible ? "visible" : "none");
}
// Ensure click handler is properly registered
map.off("click", "historical-incidents", handleIncidentClick);
map.on("click", "historical-incidents", handleIncidentClick);
} catch (error) {
console.error("Error setting up historical incidents layer:", error);
}
};
// Check if style is loaded and set up layer accordingly
if (map.isStyleLoaded()) {
setupLayerAndSource();
} else {
map.once("style.load", setupLayerAndSource);
// Fallback
setTimeout(() => {
if (map.isStyleLoaded()) {
setupLayerAndSource();
}
}, 1000);
}
return () => {
if (map) {
map.off("click", "historical-incidents", handleIncidentClick);
}
};
}, [map, visible, crimes, filterCategory, handleIncidentClick, currentYear, yearColors]);
return null;
}

View File

@ -1,77 +1,89 @@
"use client" "use client";
import { useState, useRef, useEffect, useCallback } from "react" import { useCallback, useEffect, useRef, useState } from "react";
import { useMap } from "react-map-gl/mapbox" import { useMap } from "react-map-gl/mapbox";
import { BASE_BEARING, BASE_DURATION, BASE_PITCH, BASE_ZOOM, MAPBOX_TILESET_ID, PITCH_3D, ZOOM_3D } from "@/app/_utils/const/map" import {
import DistrictPopup from "../pop-up/district-popup" BASE_BEARING,
import DistrictExtrusionLayer from "./district-extrusion-layer" BASE_DURATION,
import ClusterLayer from "./cluster-layer" BASE_PITCH,
import HeatmapLayer from "./heatmap-layer" BASE_ZOOM,
import TimelineLayer from "./timeline-layer" MAPBOX_TILESET_ID,
PITCH_3D,
ZOOM_3D,
} from "@/app/_utils/const/map";
import DistrictPopup from "../pop-up/district-popup";
import DistrictExtrusionLayer from "./district-extrusion-layer";
import ClusterLayer from "./cluster-layer";
import HeatmapLayer from "./heatmap-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 { IDistrictFeature } from "@/app/_utils/types/map";
import { createFillColorExpression, getCrimeRateColor, processCrimeDataByDistrict } from "@/app/_utils/map" import {
import UnclusteredPointLayer from "./uncluster-layer" createFillColorExpression,
getCrimeRateColor,
processCrimeDataByDistrict,
} from "@/app/_utils/map";
import { toast } from "sonner" import { toast } from "sonner";
import type { ITooltipsControl } from "../controls/top/tooltips" import type { ITooltipsControl } from "../controls/top/tooltips";
import type { IUnits } from "@/app/_utils/types/units" import type { IUnits } from "@/app/_utils/types/units";
import UnitsLayer from "./units-layer" import UnitsLayer from "./units-layer";
import DistrictFillLineLayer from "./district-layer" import DistrictFillLineLayer from "./district-layer";
import TimezoneLayer from "./timezone" import TimezoneLayer from "./timezone";
import FaultLinesLayer from "./fault-lines" import FaultLinesLayer from "./fault-lines";
import RecentIncidentsLayer from "./recent-incidents-layer" import RecentIncidentsLayer from "./recent-incidents-layer";
import IncidentPopup from "../pop-up/incident-popup" import IncidentPopup from "../pop-up/incident-popup";
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility" import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility";
import AllIncidentsLayer from "./all-incidents-layer";
// Interface for crime incident // Interface for crime incident
interface ICrimeIncident { interface ICrimeIncident {
id: string id: string;
district?: string district?: string;
category?: string category?: string;
type_category?: string | null type_category?: string | null;
description?: string description?: string;
status: string status: string;
address?: string | null address?: string | null;
timestamp?: Date timestamp?: Date;
latitude?: number latitude?: number;
longitude?: number longitude?: number;
} }
// District layer props // District layer props
export interface IDistrictLayerProps { export interface IDistrictLayerProps {
visible?: boolean visible?: boolean;
onClick?: (feature: IDistrictFeature) => void onClick?: (feature: IDistrictFeature) => void;
onDistrictClick?: (feature: IDistrictFeature) => void onDistrictClick?: (feature: IDistrictFeature) => void;
map?: any map?: any;
year: string year: string;
month: string month: string;
filterCategory: string | "all" filterCategory: string | "all";
crimes: ICrimes[] crimes: ICrimes[];
units?: IUnits[] units?: IUnits[];
tilesetId?: string tilesetId?: string;
focusedDistrictId?: string | null focusedDistrictId?: string | null;
setFocusedDistrictId?: (id: string | null) => void setFocusedDistrictId?: (id: string | null) => void;
crimeDataByDistrict?: Record<string, any> crimeDataByDistrict?: Record<string, any>;
showFill?: boolean showFill?: boolean;
activeControl?: ITooltipsControl activeControl?: ITooltipsControl;
} }
interface LayersProps { interface LayersProps {
visible?: boolean visible?: boolean;
crimes: ICrimes[] crimes: ICrimes[];
units?: IUnits[] units?: IUnits[];
recentIncidents: IIncidentLogs[] recentIncidents: IIncidentLogs[];
year: string year: string;
month: string month: string;
filterCategory: string | "all" filterCategory: string | "all";
activeControl: ITooltipsControl activeControl: ITooltipsControl;
tilesetId?: string tilesetId?: string;
useAllData?: boolean useAllData?: boolean;
showEWS?: boolean showEWS?: boolean;
sourceType?: string sourceType?: string;
} }
export default function Layers({ export default function Layers({
@ -88,32 +100,38 @@ export default function Layers({
showEWS = true, showEWS = true,
sourceType = "cbt", sourceType = "cbt",
}: LayersProps) { }: LayersProps) {
const animationRef = useRef<number | null>(null) const animationRef = useRef<number | null>(null);
const { current: map } = useMap() const { current: map } = useMap();
if (!map) { if (!map) {
toast.error("Map not found") toast.error("Map not found");
return null return null;
} }
const mapboxMap = map.getMap() const mapboxMap = map.getMap();
const [selectedDistrict, setSelectedDistrict] = useState<IDistrictFeature | null>(null) const [selectedDistrict, setSelectedDistrict] = useState<
const [selectedIncident, setSelectedIncident] = useState<ICrimeIncident | null>(null) IDistrictFeature | null
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null) >(null);
const selectedDistrictRef = useRef<IDistrictFeature | null>(null) const [selectedIncident, setSelectedIncident] = useState<
ICrimeIncident | null
>(null);
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(
null,
);
const selectedDistrictRef = useRef<IDistrictFeature | null>(null);
// Track if we're currently interacting with a marker to prevent district selection // Track if we're currently interacting with a marker to prevent district selection
const isInteractingWithMarker = useRef<boolean>(false) const isInteractingWithMarker = useRef<boolean>(false);
const crimeDataByDistrict = processCrimeDataByDistrict(crimes) const crimeDataByDistrict = processCrimeDataByDistrict(crimes);
const handlePopupClose = useCallback(() => { const handlePopupClose = useCallback(() => {
selectedDistrictRef.current = null selectedDistrictRef.current = null;
setSelectedDistrict(null) setSelectedDistrict(null);
setSelectedIncident(null) setSelectedIncident(null);
setFocusedDistrictId(null) setFocusedDistrictId(null);
isInteractingWithMarker.current = false isInteractingWithMarker.current = false;
if (map) { if (map) {
map.easeTo({ map.easeTo({
@ -122,113 +140,145 @@ export default function Layers({
bearing: BASE_BEARING, bearing: BASE_BEARING,
duration: BASE_DURATION, duration: BASE_DURATION,
easing: (t) => t * (2 - t), easing: (t) => t * (2 - t),
}) });
if (map.getLayer("clusters")) { if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "visible") map.getMap().setLayoutProperty(
"clusters",
"visibility",
"visible",
);
} }
if (map.getLayer("unclustered-point")) { if (map.getLayer("unclustered-point")) {
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible") map.getMap().setLayoutProperty(
"unclustered-point",
"visibility",
"visible",
);
} }
if (map.getLayer("district-fill")) { if (map.getLayer("district-fill")) {
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict) const fillColorExpression = createFillColorExpression(
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression as any) null,
crimeDataByDistrict,
);
map.getMap().setPaintProperty(
"district-fill",
"fill-color",
fillColorExpression as any,
);
} }
} }
}, [map, crimeDataByDistrict]) }, [map, crimeDataByDistrict]);
const animateExtrusionDown = () => { const animateExtrusionDown = () => {
if (!map || !map.getLayer("district-extrusion") || !focusedDistrictId) { if (!map || !map.getLayer("district-extrusion") || !focusedDistrictId) {
return return;
} }
if (animationRef.current) { if (animationRef.current) {
cancelAnimationFrame(animationRef.current) cancelAnimationFrame(animationRef.current);
animationRef.current = null animationRef.current = null;
} }
// Get the current height from the layer (default to 800 if not found) // Get the current height from the layer (default to 800 if not found)
let currentHeight = 800 let currentHeight = 800;
try { try {
const paint = map.getPaintProperty("district-extrusion", "fill-extrusion-height") const paint = map.getPaintProperty(
"district-extrusion",
"fill-extrusion-height",
);
if (Array.isArray(paint) && paint.length > 0) { if (Array.isArray(paint) && paint.length > 0) {
// Try to extract the current height from the expression // Try to extract the current height from the expression
const idx = paint.findIndex((v) => v === focusedDistrictId) const idx = paint.findIndex((v) => v === focusedDistrictId);
if (idx !== -1 && typeof paint[idx + 1] === "number") { if (idx !== -1 && typeof paint[idx + 1] === "number") {
currentHeight = paint[idx + 1] currentHeight = paint[idx + 1];
} }
} }
} catch { } catch {
// fallback to default // fallback to default
} }
const startHeight = currentHeight const startHeight = currentHeight;
const targetHeight = 0 const targetHeight = 0;
const duration = 700 const duration = 700;
const startTime = performance.now() const startTime = performance.now();
const animate = (currentTime: number) => { const animate = (currentTime: number) => {
const elapsed = currentTime - startTime const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1) const progress = Math.min(elapsed / duration, 1);
const easedProgress = progress * (2 - progress) const easedProgress = progress * (2 - progress);
const newHeight = startHeight + (targetHeight - startHeight) * easedProgress const newHeight = startHeight +
(targetHeight - startHeight) * easedProgress;
try { try {
map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-height", [ map.getMap().setPaintProperty(
"case", "district-extrusion",
["has", "kode_kec"], "fill-extrusion-height",
["match", ["get", "kode_kec"], focusedDistrictId, newHeight, 0],
0,
])
map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-color", [
"case",
["has", "kode_kec"],
[ [
"match", "case",
["get", "kode_kec"], ["has", "kode_kec"],
focusedDistrictId || "", [
"transparent", "match",
["get", "kode_kec"],
focusedDistrictId,
newHeight,
0,
],
0,
],
);
map.getMap().setPaintProperty(
"district-extrusion",
"fill-extrusion-color",
[
"case",
["has", "kode_kec"],
[
"match",
["get", "kode_kec"],
focusedDistrictId || "",
"transparent",
"transparent",
],
"transparent", "transparent",
], ],
"transparent", );
])
if (progress < 1) { if (progress < 1) {
animationRef.current = requestAnimationFrame(animate) animationRef.current = requestAnimationFrame(animate);
} else { } else {
animationRef.current = null animationRef.current = null;
} }
} catch (error) { } catch (error) {
if (animationRef.current) { if (animationRef.current) {
cancelAnimationFrame(animationRef.current) cancelAnimationFrame(animationRef.current);
animationRef.current = null animationRef.current = null;
} }
} }
} };
animationRef.current = requestAnimationFrame(animate) animationRef.current = requestAnimationFrame(animate);
} };
const handleCloseDistrictPopup = useCallback(() => { const handleCloseDistrictPopup = useCallback(() => {
animateExtrusionDown() animateExtrusionDown();
handlePopupClose() handlePopupClose();
}, [handlePopupClose, animateExtrusionDown]) }, [handlePopupClose, animateExtrusionDown]);
const handleDistrictClick = useCallback( const handleDistrictClick = useCallback(
(feature: IDistrictFeature) => { (feature: IDistrictFeature) => {
if (isInteractingWithMarker.current) { if (isInteractingWithMarker.current) {
return return;
} }
setSelectedIncident(null) setSelectedIncident(null);
setSelectedDistrict(feature) setSelectedDistrict(feature);
selectedDistrictRef.current = feature selectedDistrictRef.current = feature;
setFocusedDistrictId(feature.id) setFocusedDistrictId(feature.id);
if (map && feature.longitude && feature.latitude) { if (map && feature.longitude && feature.latitude) {
map.flyTo({ map.flyTo({
@ -238,27 +288,36 @@ export default function Layers({
bearing: BASE_BEARING, bearing: BASE_BEARING,
duration: BASE_DURATION, duration: BASE_DURATION,
easing: (t) => t * (2 - t), easing: (t) => t * (2 - t),
}) });
if (map.getLayer("clusters")) { if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "none") map.getMap().setLayoutProperty(
"clusters",
"visibility",
"none",
);
} }
if (map.getLayer("unclustered-point")) { if (map.getLayer("unclustered-point")) {
map.getMap().setLayoutProperty("unclustered-point", "visibility", "none") map.getMap().setLayoutProperty(
"unclustered-point",
"visibility",
"none",
);
} }
} }
}, },
[map], [map],
) );
useEffect(() => { useEffect(() => {
if (!mapboxMap) return if (!mapboxMap) return;
const handleFlyToEvent = (e: Event) => { const handleFlyToEvent = (e: Event) => {
const customEvent = e as CustomEvent const customEvent = e as CustomEvent;
if (!map || !customEvent.detail) return if (!map || !customEvent.detail) return;
const { longitude, latitude, zoom, bearing, pitch, duration } = customEvent.detail const { longitude, latitude, zoom, bearing, pitch, duration } =
customEvent.detail;
map.flyTo({ map.flyTo({
center: [longitude, latitude], center: [longitude, latitude],
@ -266,49 +325,79 @@ export default function Layers({
bearing: bearing || 0, bearing: bearing || 0,
pitch: pitch || 45, pitch: pitch || 45,
duration: duration || 2000, duration: duration || 2000,
}) });
} };
mapboxMap.getCanvas().addEventListener("mapbox_fly_to", handleFlyToEvent as EventListener) mapboxMap.getCanvas().addEventListener(
"mapbox_fly_to",
handleFlyToEvent as EventListener,
);
return () => { return () => {
if (mapboxMap && mapboxMap.getCanvas()) { if (mapboxMap && mapboxMap.getCanvas()) {
mapboxMap.getCanvas().removeEventListener("mapbox_fly_to", handleFlyToEvent as EventListener) mapboxMap.getCanvas().removeEventListener(
"mapbox_fly_to",
handleFlyToEvent as EventListener,
);
} }
} };
}, [mapboxMap, map]) }, [mapboxMap, map]);
useEffect(() => { useEffect(() => {
if (selectedDistrictRef.current) { if (selectedDistrictRef.current) {
const districtId = selectedDistrictRef.current.id const districtId = selectedDistrictRef.current.id;
const districtCrime = crimes.find((crime) => crime.district_id === districtId) const districtCrime = crimes.find((crime) =>
crime.district_id === districtId
);
if (districtCrime) { if (districtCrime) {
const selectedYearNum = year ? Number.parseInt(year) : new Date().getFullYear() const selectedYearNum = year
? Number.parseInt(year)
: new Date().getFullYear();
let demographics = districtCrime.districts.demographics?.find((d) => d.year === selectedYearNum) let demographics = districtCrime.districts.demographics?.find((
d,
) => d.year === selectedYearNum);
if (!demographics && districtCrime.districts.demographics?.length) { if (
demographics = districtCrime.districts.demographics.sort((a, b) => b.year - a.year)[0] !demographics &&
districtCrime.districts.demographics?.length
) {
demographics =
districtCrime.districts.demographics.sort((a, b) =>
b.year - a.year
)[0];
} }
let geographics = districtCrime.districts.geographics?.find((g) => g.year === selectedYearNum) let geographics = districtCrime.districts.geographics?.find((
g,
) => g.year === selectedYearNum);
if (!geographics && districtCrime.districts.geographics?.length) { if (
!geographics && districtCrime.districts.geographics?.length
) {
const validGeographics = districtCrime.districts.geographics const validGeographics = districtCrime.districts.geographics
.filter((g) => g.year !== null) .filter((g) => g.year !== null)
.sort((a, b) => (b.year || 0) - (a.year || 0)) .sort((a, b) => (b.year || 0) - (a.year || 0));
geographics = validGeographics.length > 0 ? validGeographics[0] : districtCrime.districts.geographics[0] geographics = validGeographics.length > 0
? validGeographics[0]
: districtCrime.districts.geographics[0];
} }
if (!demographics || !geographics) { if (!demographics || !geographics) {
console.error("Missing district data:", { demographics, geographics }) console.error("Missing district data:", {
return demographics,
geographics,
});
return;
} }
const crime_incidents = districtCrime.crime_incidents const crime_incidents = districtCrime.crime_incidents
.filter((incident) => filterCategory === "all" || incident.crime_categories.name === filterCategory) .filter((incident) =>
filterCategory === "all" ||
incident.crime_categories.name === filterCategory
)
.map((incident) => ({ .map((incident) => ({
id: incident.id, id: incident.id,
timestamp: incident.timestamp, timestamp: incident.timestamp,
@ -319,12 +408,14 @@ export default function Layers({
address: incident.locations.address || "", address: incident.locations.address || "",
latitude: incident.locations.latitude, latitude: incident.locations.latitude,
longitude: incident.locations.longitude, longitude: incident.locations.longitude,
})) }));
const updatedDistrict: IDistrictFeature = { const updatedDistrict: IDistrictFeature = {
...selectedDistrictRef.current, ...selectedDistrictRef.current,
number_of_crime: crimeDataByDistrict[districtId]?.number_of_crime || 0, number_of_crime:
level: crimeDataByDistrict[districtId]?.level || selectedDistrictRef.current.level, crimeDataByDistrict[districtId]?.number_of_crime || 0,
level: crimeDataByDistrict[districtId]?.level ||
selectedDistrictRef.current.level,
demographics: { demographics: {
number_of_unemployed: demographics.number_of_unemployed, number_of_unemployed: demographics.number_of_unemployed,
population: demographics.population, population: demographics.population,
@ -341,60 +432,84 @@ export default function Layers({
crime_incidents, crime_incidents,
selectedYear: year, selectedYear: year,
selectedMonth: month, selectedMonth: month,
} };
selectedDistrictRef.current = updatedDistrict selectedDistrictRef.current = updatedDistrict;
setSelectedDistrict((prevDistrict) => { setSelectedDistrict((prevDistrict) => {
if ( if (
prevDistrict?.id === updatedDistrict.id && prevDistrict?.id === updatedDistrict.id &&
prevDistrict?.selectedYear === updatedDistrict.selectedYear && prevDistrict?.selectedYear ===
prevDistrict?.selectedMonth === updatedDistrict.selectedMonth updatedDistrict.selectedYear &&
prevDistrict?.selectedMonth ===
updatedDistrict.selectedMonth
) { ) {
return prevDistrict return prevDistrict;
} }
return updatedDistrict return updatedDistrict;
}) });
} }
} }
}, [crimes, filterCategory, year, month, crimeDataByDistrict]) }, [crimes, filterCategory, year, month, crimeDataByDistrict]);
const handleSetFocusedDistrictId = useCallback((id: string | null, isMarkerClick = false) => { const handleSetFocusedDistrictId = useCallback(
if (isMarkerClick) { (id: string | null, isMarkerClick = false) => {
isInteractingWithMarker.current = true if (isMarkerClick) {
isInteractingWithMarker.current = true;
setTimeout(() => { setTimeout(() => {
isInteractingWithMarker.current = false isInteractingWithMarker.current = false;
}, 1000) }, 1000);
} }
setFocusedDistrictId(id) setFocusedDistrictId(id);
}, []) },
[],
);
const crimesVisible = activeControl === "incidents" const showHeatmapLayer = activeControl === "heatmap" &&
const showHeatmapLayer = activeControl === "heatmap" && sourceType !== "cbu" sourceType !== "cbu";
const showUnitsLayer = activeControl === "units" const showUnitsLayer = activeControl === "units";
const showTimelineLayer = activeControl === "timeline" const showTimelineLayer = activeControl === "timeline";
const showHistoricalLayer = activeControl === "historical" const showRecentIncidents = activeControl === "recents";
const showRecentIncidents = activeControl === "recents" const showAllIncidents = activeControl === "incidents"; // Use the "incidents" tooltip for all incidents
const showDistrictFill = const showDistrictFill = activeControl === "incidents" ||
activeControl === "incidents" ||
activeControl === "clusters" || activeControl === "clusters" ||
activeControl === "historical" || activeControl === "recents";
activeControl === "recents" const showIncidentMarkers = activeControl !== "heatmap" &&
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline" && sourceType !== "cbu" activeControl !== "timeline" && sourceType !== "cbu";
const shouldShowExtrusion = focusedDistrictId !== null && !isInteractingWithMarker.current const shouldShowExtrusion = focusedDistrictId !== null &&
!isInteractingWithMarker.current;
useEffect(() => { useEffect(() => {
if (!mapboxMap) return; if (!mapboxMap) return;
const recentLayerIds = ["very-recent-incidents-pulse", "recent-incidents-glow", "recent-incidents"]; const recentLayerIds = [
"very-recent-incidents-pulse",
"recent-incidents-glow",
"recent-incidents",
];
const timelineLayerIds = ["timeline-markers-bg", "timeline-markers"]; const timelineLayerIds = ["timeline-markers-bg", "timeline-markers"];
const heatmapLayerIds = ["heatmap-layer"]; const heatmapLayerIds = ["heatmap-layer"];
const unitsLayerIds = ["units-points", "incidents-points", "units-labels", "units-connection-lines"]; const unitsLayerIds = [
const clusterLayerIds = ["clusters", "cluster-count", "crime-points", "crime-count-labels"]; "units-points",
"incidents-points",
"units-labels",
"units-connection-lines",
];
const clusterLayerIds = [
"clusters",
"cluster-count",
"crime-points",
"crime-count-labels",
];
const unclusteredLayerIds = ["unclustered-point"]; const unclusteredLayerIds = ["unclustered-point"];
const allIncidentsLayerIds = [
"all-incidents-pulse",
"all-incidents-circles",
"all-incidents",
];
if (activeControl !== "recents") { if (activeControl !== "recents") {
manageLayerVisibility(mapboxMap, recentLayerIds, false); manageLayerVisibility(mapboxMap, recentLayerIds, false);
@ -416,10 +531,13 @@ export default function Layers({
manageLayerVisibility(mapboxMap, clusterLayerIds, false); manageLayerVisibility(mapboxMap, clusterLayerIds, false);
} }
if (activeControl !== "incidents" && activeControl !== "recents" && activeControl !== "historical") { if (activeControl !== "incidents") {
manageLayerVisibility(mapboxMap, unclusteredLayerIds, false); manageLayerVisibility(mapboxMap, allIncidentsLayerIds, false);
} }
if (activeControl !== "incidents" && activeControl !== "recents") {
manageLayerVisibility(mapboxMap, unclusteredLayerIds, false);
}
}, [activeControl, mapboxMap]); }, [activeControl, mapboxMap]);
return ( return (
@ -450,6 +568,13 @@ export default function Layers({
/> />
)} )}
<AllIncidentsLayer
visible={showAllIncidents}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}
/>
<RecentIncidentsLayer <RecentIncidentsLayer
visible={showRecentIncidents} visible={showRecentIncidents}
map={mapboxMap} map={mapboxMap}
@ -496,15 +621,8 @@ export default function Layers({
sourceType={sourceType} sourceType={sourceType}
/> />
<UnclusteredPointLayer {selectedDistrict && !selectedIncident &&
visible={visible && showIncidentMarkers && !focusedDistrictId} !isInteractingWithMarker.current && (
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}
focusedDistrictId={focusedDistrictId}
/>
{selectedDistrict && !selectedIncident && !isInteractingWithMarker.current && (
<DistrictPopup <DistrictPopup
longitude={selectedDistrict.longitude || 0} longitude={selectedDistrict.longitude || 0}
latitude={selectedDistrict.latitude || 0} latitude={selectedDistrict.latitude || 0}
@ -520,5 +638,5 @@ export default function Layers({
<FaultLinesLayer map={mapboxMap} /> <FaultLinesLayer map={mapboxMap} />
</> </>
) );
} }

View File

@ -1,229 +0,0 @@
"use client"
import type { IUnclusteredPointLayerProps } from "@/app/_utils/types/map"
import { useEffect, useCallback, useRef } from "react"
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility"
export default function UnclusteredPointLayer({
visible = true,
map,
crimes = [],
filterCategory = "all",
focusedDistrictId,
}: IUnclusteredPointLayerProps) {
// Add a ref to track if we're currently interacting with a marker
const isInteractingWithMarker = useRef(false);
// Define layer IDs for consistent management
const LAYER_IDS = ['unclustered-point'];
const handleIncidentClick = useCallback(
(e: any) => {
if (!map) return
const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] })
if (!features || features.length === 0) return
// Set flag to indicate we're interacting with a marker
isInteractingWithMarker.current = true;
const incident = features[0]
if (!incident.properties) return
e.originalEvent.stopPropagation()
e.preventDefault()
const incidentDetails = {
id: incident.properties.id,
district: incident.properties.district,
category: incident.properties.category,
type: incident.properties.incidentType,
description: incident.properties.description,
status: incident.properties?.status || "Unknown",
longitude: (incident.geometry as any).coordinates[0],
latitude: (incident.geometry as any).coordinates[1],
timestamp: new Date(incident.properties.timestamp || Date.now()),
}
// console.log("Incident clicked:", incidentDetails)
// Ensure markers stay visible when clicking on them
if (map.getLayer("unclustered-point")) {
map.setLayoutProperty("unclustered-point", "visibility", "visible");
}
// First fly to the incident location
map.flyTo({
center: [incidentDetails.longitude, incidentDetails.latitude],
zoom: 15,
bearing: 0,
pitch: 45,
duration: 2000,
})
// Then dispatch the incident_click event to show the popup
const customEvent = new CustomEvent("incident_click", {
detail: incidentDetails,
bubbles: true,
})
// Dispatch on both the map canvas and document to ensure it's caught
map.getCanvas().dispatchEvent(customEvent)
document.dispatchEvent(customEvent)
// Reset the flag after a delay to allow the event to process
setTimeout(() => {
isInteractingWithMarker.current = false;
}, 500);
},
[map],
);
// Use centralized layer visibility management
useEffect(() => {
if (!map) return;
// Special case for this layer: also consider focusedDistrictId
const isActuallyVisible = visible && !(focusedDistrictId && !isInteractingWithMarker.current);
return manageLayerVisibility(map, LAYER_IDS, isActuallyVisible);
}, [map, visible, focusedDistrictId]);
useEffect(() => {
if (!map || !visible) return
// Konversi crimes ke GeoJSON FeatureCollection
const geojsonData = {
type: "FeatureCollection" as const,
features: crimes.flatMap((crime) =>
crime.crime_incidents
.filter(
(incident) =>
(filterCategory === "all" || incident.crime_categories.name === filterCategory) &&
incident.locations &&
typeof incident.locations.longitude === "number" &&
typeof incident.locations.latitude === "number",
)
.map((incident) => ({
type: "Feature" as const,
geometry: {
type: "Point" as const,
coordinates: [incident.locations.longitude, incident.locations.latitude],
},
properties: {
id: incident.id,
district: crime.districts.name,
category: incident.crime_categories.name,
incidentType: incident.crime_categories.type || "",
description: incident.description,
status: incident.status || "",
timestamp: incident.timestamp ? incident.timestamp.toString() : "",
},
})),
),
}
const setupLayerAndSource = () => {
try {
// First check if source exists and update it
if (map.getSource("crime-incidents")) {
; (map.getSource("crime-incidents") as any).setData(geojsonData)
} else {
// If not, add source
map.addSource("crime-incidents", {
type: "geojson",
data: geojsonData,
})
}
// Get layers to find first symbol layer
const layers = map.getStyle().layers
let firstSymbolId: string | undefined
for (const layer of layers) {
if (layer.type === "symbol") {
firstSymbolId = layer.id
break
}
}
// Check if layer exists
if (!map.getLayer("unclustered-point")) {
map.addLayer(
{
id: "unclustered-point",
type: "circle",
source: "crime-incidents",
filter: ["!", ["has", "point_count"]],
paint: {
"circle-color": "#11b4da",
"circle-radius": 8,
"circle-stroke-width": 1,
"circle-stroke-color": "#fff",
},
layout: {
// Only hide markers if a district is focused AND we're not interacting with a marker
visibility: focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible",
},
},
firstSymbolId,
)
map.on("mouseenter", "unclustered-point", () => {
map.getCanvas().style.cursor = "pointer"
})
map.on("mouseleave", "unclustered-point", () => {
map.getCanvas().style.cursor = ""
})
} else {
// Update visibility based on focused district, but keep visible when interacting with markers
const newVisibility = focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible";
map.setLayoutProperty("unclustered-point", "visibility", newVisibility);
}
// Always ensure click handler is properly registered
map.off("click", "unclustered-point", handleIncidentClick)
map.on("click", "unclustered-point", handleIncidentClick)
} catch (error) {
console.error("Error setting up unclustered point layer:", error)
}
}
if (map.getLayer("crime-incidents")) {
const newVisibility = focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible";
map.setLayoutProperty("crime-incidents", "visibility", newVisibility);
}
if (map.getLayer("unclustered-point")) {
const newVisibility = focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible";
map.setLayoutProperty("unclustered-point", "visibility", newVisibility);
}
// Check if style is loaded and set up layer accordingly
if (map.isStyleLoaded()) {
setupLayerAndSource()
} else {
// Add event listener for style loading completion
const onStyleLoad = () => {
setupLayerAndSource()
}
map.once("style.load", onStyleLoad)
// Also wait a bit and try again as a fallback
setTimeout(() => {
if (map.isStyleLoaded()) {
setupLayerAndSource()
}
}, 500)
}
return () => {
if (map) {
map.off("click", "unclustered-point", handleIncidentClick)
}
}
}, [map, visible, focusedDistrictId, handleIncidentClick, crimes, filterCategory])
return null
}