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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,39 +1,57 @@
"use client"
"use client";
import { Button } from "@/app/_components/ui/button"
import { Tooltip, TooltipContent, 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 { Button } from "@/app/_components/ui/button";
import {
Tooltip,
TooltipContent,
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 type { ITooltipsControl } from "./tooltips"
import MonthSelector from "../month-selector"
import YearSelector from "../year-selector"
import CategorySelector from "../category-selector"
import SourceTypeSelector from "../source-type-selector"
import { useEffect, useRef, useState } from "react";
import type { ITooltipsControl } from "./tooltips";
import MonthSelector from "../month-selector";
import YearSelector from "../year-selector";
import CategorySelector from "../category-selector";
import SourceTypeSelector from "../source-type-selector";
// Define the additional tools and features
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 {
activeControl?: string
onControlChange?: (controlId: ITooltipsControl) => void
selectedYear: number
setSelectedYear: (year: number) => void
selectedMonth: number | "all"
setSelectedMonth: (month: number | "all") => void
selectedCategory: string | "all"
setSelectedCategory: (category: string | "all") => void
selectedSourceType: string
setSelectedSourceType: (sourceType: string) => void
availableYears?: (number | null)[]
availableSourceTypes?: string[]
categories?: string[]
panicButtonTriggered?: boolean
activeControl?: string;
onControlChange?: (controlId: ITooltipsControl) => void;
selectedYear: number | "all";
setSelectedYear: (year: number | "all") => void;
selectedMonth: number | "all";
setSelectedMonth: (month: number | "all") => void;
selectedCategory: string | "all";
setSelectedCategory: (category: string | "all") => void;
selectedSourceType: string;
setSelectedSourceType: (sourceType: string) => void;
availableYears?: (number | null)[];
availableSourceTypes?: string[];
categories?: string[];
panicButtonTriggered?: boolean;
disableYearMonth?: boolean;
}
export default function AdditionalTooltips({
@ -51,147 +69,188 @@ export default function AdditionalTooltips({
availableSourceTypes = [],
categories = [],
panicButtonTriggered = false,
disableYearMonth = false,
}: AdditionalTooltipsProps) {
const [showSelectors, setShowSelectors] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const [isClient, setIsClient] = useState(false)
const [showSelectors, setShowSelectors] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const [isClient, setIsClient] = useState(false);
const container = isClient ? document.getElementById("root") : null
const container = isClient ? document.getElementById("root") : null;
useEffect(() => {
if (panicButtonTriggered && activeControl !== "alerts" && onControlChange) {
onControlChange("alerts")
}
}, [panicButtonTriggered, activeControl, onControlChange])
if (
panicButtonTriggered && activeControl !== "alerts" &&
onControlChange
) {
onControlChange("alerts");
}
}, [panicButtonTriggered, activeControl, onControlChange]);
useEffect(() => {
setIsClient(true)
}, [])
setIsClient(true);
}, []);
const isControlDisabled = (controlId: ITooltipsControl) => {
// When source type is CBU, disable all controls except for layers
return selectedSourceType === "cbu" && controlId !== "layers"
}
return selectedSourceType === "cbu" && controlId !== "layers";
};
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>
{additionalTooltips.map((control) => {
const isButtonDisabled = isControlDisabled(control.id)
{additionalTooltips.map((control) => {
const isButtonDisabled = isControlDisabled(control.id);
return (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id ? "default" : "ghost"}
size="medium"
className={`h-8 w-8 rounded-md ${isButtonDisabled
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
: activeControl === control.id
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
: "text-white hover:bg-emerald-500/90 hover:text-background"
} ${control.id === "alerts" && panicButtonTriggered ? "animate-pulse ring-2 ring-red-500" : ""}`}
onClick={() => onControlChange?.(control.id)}
disabled={isButtonDisabled}
aria-disabled={isButtonDisabled}
return (
<Tooltip key={control.id}>
<TooltipTrigger asChild>
<Button
variant={activeControl === control.id
? "default"
: "ghost"}
size="medium"
className={`h-8 w-8 rounded-md ${
isButtonDisabled
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
: activeControl === control.id
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
: "text-white hover:bg-emerald-500/90 hover:text-background"
} ${
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}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{isButtonDisabled ? "Not available for CBU data" : control.label}</p>
</TooltipContent>
</Tooltip>
)
})}
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-white hover:bg-emerald-500/90 hover:text-background"
onClick={() =>
setShowSelectors(!showSelectors)}
>
<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>
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-md text-white hover:bg-emerald-500/90 hover:text-background"
onClick={() => setShowSelectors(!showSelectors)}
>
<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]"
/>
</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>
)}
</>
)
{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 { IconChartBubble, IconClock } from "@tabler/icons-react"
// Define the primary crime data controls
// Update the tooltip for "incidents" to "All Incidents"
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: "units" as ITooltipsControl, icon: <Building size={20} />, label: "Police Units" },
{ 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 =
// Crime data views
| "incidents"
| "historical"
| "heatmap"
| "units"
| "patrol"
@ -42,8 +41,8 @@ interface TooltipProps {
selectedSourceType: string
setSelectedSourceType: (sourceType: string) => void
availableSourceTypes: string[] // This must be string[] to match with API response
selectedYear: number
setSelectedYear: (year: number) => void
selectedYear: number | "all"
setSelectedYear: (year: number | "all") => void
selectedMonth: number | "all"
setSelectedMonth: (month: number | "all") => void
selectedCategory: string | "all"
@ -51,6 +50,7 @@ interface TooltipProps {
availableYears?: (number | null)[]
categories?: string[]
crimes?: any[] // Add this prop to receive crime data
disableYearMonth?: boolean
}
export default function Tooltips({
@ -68,6 +68,7 @@ export default function Tooltips({
availableYears = [],
categories = [],
crimes = [],
disableYearMonth = false,
}: TooltipProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [isClient, setIsClient] = useState(false)
@ -97,6 +98,7 @@ export default function Tooltips({
setSelectedCategory={setSelectedCategory}
availableYears={availableYears}
categories={categories}
disableYearMonth={disableYearMonth}
/>
{/* 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 { createRoot } from "react-dom/client"
import { useRef, useEffect, useState } from "react"
import { Skeleton } from "../../ui/skeleton"
interface YearSelectorProps {
availableYears?: (number | null)[]
selectedYear: number
onYearChange: (year: number) => void
isLoading?: boolean
className?: string
}
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/app/_components/ui/select";
import { createRoot } from "react-dom/client";
import { useEffect, useRef, useState } from "react";
import { Skeleton } from "../../ui/skeleton";
interface YearSelectorProps {
availableYears?: (number | null)[];
selectedYear: number;
onYearChange: (year: number) => void;
selectedYear: number | "all";
onYearChange: (year: number | "all") => void;
includeAllOption?: boolean;
isLoading?: boolean;
className?: string;
disabled?: boolean;
}
// React component for the year selector UI
@ -26,8 +26,10 @@ function YearSelectorUI({
availableYears = [],
selectedYear,
onYearChange,
includeAllOption = true,
isLoading = false,
className = "w-[120px]"
className = "w-[120px]",
disabled = false,
}: YearSelectorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [isClient, setIsClient] = useState(false);
@ -41,36 +43,54 @@ function YearSelectorUI({
const container = isClient ? document.getElementById("root") : null;
return (
<div ref={containerRef} className="mapboxgl-year-selector">
{isLoading ? (
<div className=" h-8">
<Skeleton className="h-full w-full rounded-md" />
</div>
) : (
<Select
value={selectedYear.toString()}
onValueChange={(value) => onYearChange(Number(value))}
disabled={isLoading}
>
<SelectTrigger className={className}>
<SelectValue placeholder="Year" />
</SelectTrigger>
{/* Ensure that the dropdown content renders correctly only on the client side */}
<div ref={containerRef} className="mapboxgl-month-selector">
{isLoading
? (
<div className="flex items-center justify-center h-8">
<Skeleton className="h-full w-full rounded-md" />
</div>
)
: (
<Select
value={selectedYear.toString()}
onValueChange={(value) =>
onYearChange(
value === "all" ? "all" : Number(value),
)}
disabled={disabled}
>
<SelectTrigger
className={`${className} ${
disabled
? "opacity-60 cursor-not-allowed bg-muted"
: ""
}`}
>
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent
container={containerRef.current || container || undefined}
container={containerRef.current || container ||
undefined}
style={{ zIndex: 2000 }}
className={`${className}`}
>
{availableYears
?.filter((year) => year !== null)
.map((year) => (
<SelectItem key={year} value={year!.toString()}>
{includeAllOption && (
<SelectItem value="all">All Years</SelectItem>
)}
{availableYears.map((year) => (
year !== null && (
<SelectItem
key={year}
value={year.toString()}
>
{year}
</SelectItem>
))}
)
))}
</SelectContent>
</Select>
)}
)}
</div>
);
}
@ -88,14 +108,14 @@ export class YearSelectorControl {
onAdd(map: any) {
this._map = map;
this._container = document.createElement('div');
this._container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group';
this._container.style.padding = '5px';
this._container = document.createElement("div");
this._container.className = "mapboxgl-ctrl mapboxgl-ctrl-group";
this._container.style.padding = "5px";
// 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
this._container.style.zIndex = '50';
this._container.style.zIndex = "50";
// Create React root for rendering our component
this._root = createRoot(this._container);

View File

@ -1,96 +1,143 @@
"use client"
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
import { Skeleton } from "@/app/_components/ui/skeleton"
import MapView from "./map"
import { Button } from "@/app/_components/ui/button"
import { AlertCircle } from "lucide-react"
import { getMonthName } from "@/app/_utils/common"
import { useRef, useState, useCallback, useMemo, useEffect } from "react"
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
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 {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/app/_components/ui/card";
import { Skeleton } from "@/app/_components/ui/skeleton";
import MapView from "./map";
import { Button } from "@/app/_components/ui/button";
import { AlertCircle } from "lucide-react";
import { getMonthName } from "@/app/_utils/common";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useFullscreen } from "@/app/_hooks/use-fullscreen";
import { Overlay } from "./overlay";
import MapLegend from "./legends/map-legend";
import 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 { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client"
import { CrimeTimelapse } from "./controls/bottom/crime-timelapse"
import { ITooltipsControl } from "./controls/top/tooltips"
import CrimeSidebar from "./controls/left/sidebar/map-sidebar"
import Tooltips from "./controls/top/tooltips"
import Layers from "./layers/layers"
import { cn } from "@/app/_lib/utils";
import {
$Enums,
crime_categories,
crime_incidents,
crimes,
demographics,
districts,
geographics,
locations,
} from "@prisma/client";
import { CrimeTimelapse } from "./controls/bottom/crime-timelapse";
import { ITooltipsControl } from "./controls/top/tooltips";
import CrimeSidebar from "./controls/left/sidebar/map-sidebar";
import Tooltips from "./controls/top/tooltips";
import Layers from "./layers/layers";
import { useGetUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries"
import { IDistrictFeature } from "@/app/_utils/types/map"
import EWSAlertLayer from "./layers/ews-alert-layer"
import { IIncidentLog } from "@/app/_utils/types/ews"
import { addMockIncident, getAllIncidents, resolveIncident } from "@/app/_utils/mock/ews-data"
import { useMap } from "react-map-gl/mapbox"
import PanicButtonDemo from "./controls/panic-button-demo"
import { useGetUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries";
import { IDistrictFeature } from "@/app/_utils/types/map";
import EWSAlertLayer from "./layers/ews-alert-layer";
import { IIncidentLog } from "@/app/_utils/types/ews";
import {
addMockIncident,
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() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
const [selectedDistrict, setSelectedDistrict] = useState<IDistrictFeature | null>(null)
const [showLegend, setShowLegend] = useState<boolean>(true)
const [activeControl, setActiveControl] = useState<ITooltipsControl>("clusters")
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu")
const [selectedYear, setSelectedYear] = useState<number>(2024)
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
const [yearProgress, setYearProgress] = useState(0)
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
const [isSearchActive, setIsSearchActive] = useState(false)
const [showUnitsLayer, setShowUnitsLayer] = useState(false)
const [showClusters, setShowClusters] = useState(false)
const [showHeatmap, setShowHeatmap] = useState(false)
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 [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const [activeControl, setActiveControl] = useState<ITooltipsControl>(
"clusters",
);
const [selectedDistrict, setSelectedDistrict] = useState<
IDistrictFeature | null
>(null);
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu");
const [selectedYear, setSelectedYear] = useState<number | "all">(2024);
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all");
const [selectedCategory, setSelectedCategory] = useState<string | "all">(
"all",
);
const [ewsIncidents, setEwsIncidents] = useState<IIncidentLog[]>([]);
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 {
data: availableYears,
isLoading: isYearsLoading,
error: yearsError
} = useGetAvailableYears()
error: yearsError,
} = useGetAvailableYears();
const { data: categoriesData, isLoading: isCategoryLoading } = useGetCrimeCategories()
const { data: categoriesData, isLoading: isCategoryLoading } =
useGetCrimeCategories();
const categories = useMemo(() =>
categoriesData ? categoriesData.map(category => category.name) : []
, [categoriesData])
const categories = useMemo(
() =>
categoriesData
? categoriesData.map((category) => category.name)
: [],
[categoriesData],
);
const {
data: crimes,
isLoading: isCrimesLoading,
error: crimesError
} = useGetCrimes()
error: crimesError,
} = useGetCrimes();
const { data: fetchedUnits, isLoading } = useGetUnitsQuery()
const { data: fetchedUnits, isLoading } = useGetUnitsQuery();
const { data: recentIncidents } = useGetRecentIncidents()
const { data: recentIncidents } = useGetRecentIncidents();
useEffect(() => {
if (activeControl === "heatmap" || activeControl === "timeline") {
if (
activeControl === "heatmap" || activeControl === "timeline" ||
activeControl === "incidents"
) {
setSelectedYear("all");
setUseAllYears(true);
setUseAllMonths(true);
} else {
setSelectedYear(2024);
setUseAllYears(false);
setUseAllMonths(false);
}
@ -98,7 +145,9 @@ export default function CrimeMap() {
const crimesBySourceType = useMemo(() => {
if (!crimes) return [];
return crimes.filter(crime => crime.source_type === selectedSourceType);
return crimes.filter((crime) =>
crime.source_type === selectedSourceType
);
}, [crimes, selectedSourceType]);
const filteredByYearAndMonth = useMemo(() => {
@ -109,7 +158,9 @@ export default function CrimeMap() {
return crimesBySourceType;
} else {
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;
}
});
}, [crimesBySourceType, selectedYear, selectedMonth, useAllYears, useAllMonths]);
}, [
crimesBySourceType,
selectedYear,
selectedMonth,
useAllYears,
useAllMonths,
]);
const filteredCrimes = useMemo(() => {
if (!filteredByYearAndMonth) return []
if (selectedCategory === "all") return filteredByYearAndMonth
if (!filteredByYearAndMonth) return [];
if (selectedCategory === "all") return filteredByYearAndMonth;
return filteredByYearAndMonth.map((crime) => {
const filteredIncidents = crime.crime_incidents.filter(
incident => incident.crime_categories.name === selectedCategory
)
(incident) =>
incident.crime_categories.name === selectedCategory,
);
return {
...crime,
crime_incidents: filteredIncidents,
number_of_crime: filteredIncidents.length
}
})
}, [filteredByYearAndMonth, selectedCategory])
number_of_crime: filteredIncidents.length,
};
});
}, [filteredByYearAndMonth, selectedCategory]);
useEffect(() => {
if (selectedSourceType === "cbu") {
if (activeControl !== "clusters" && activeControl !== "reports" &&
if (
activeControl !== "clusters" && activeControl !== "reports" &&
activeControl !== "layers" && activeControl !== "search" &&
activeControl !== "alerts") {
activeControl !== "alerts"
) {
setActiveControl("clusters");
setShowClusters(true);
setShowUnclustered(false);
}
}
}, [selectedSourceType, activeControl]);
useEffect(() => {
setEwsIncidents(getAllIncidents())
}, [])
setEwsIncidents(getAllIncidents());
}, []);
const handleTriggerAlert = useCallback((priority: "high" | "medium" | "low") => {
const newIncident = addMockIncident({ priority })
setEwsIncidents(getAllIncidents())
}, [])
const handleTriggerAlert = useCallback(
(priority: "high" | "medium" | "low") => {
const newIncident = addMockIncident({ priority });
setEwsIncidents(getAllIncidents());
},
[],
);
const handleResolveIncident = useCallback((id: string) => {
resolveIncident(id)
setEwsIncidents(getAllIncidents())
}, [])
resolveIncident(id);
setEwsIncidents(getAllIncidents());
}, []);
const handleResolveAllAlerts = useCallback(() => {
ewsIncidents.forEach((incident) => {
if (incident.status === "active") {
resolveIncident(incident.id)
resolveIncident(incident.id);
}
})
setEwsIncidents(getAllIncidents())
}, [ewsIncidents])
});
setEwsIncidents(getAllIncidents());
}, [ewsIncidents]);
const handleSourceTypeChange = useCallback((sourceType: string) => {
setSelectedSourceType(sourceType);
@ -183,37 +245,40 @@ export default function CrimeMap() {
if (sourceType === "cbu") {
setActiveControl("clusters");
setShowClusters(true);
setShowUnclustered(false);
} else {
setActiveControl("clusters");
setShowUnclustered(true);
setShowClusters(false);
}
}, []);
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
setSelectedYear(year)
setSelectedMonth(month)
setYearProgress(progress)
}, [])
const handleTimelineChange = useCallback(
(year: number, month: number, progress: number) => {
setSelectedYear(year);
setSelectedMonth(month);
setYearProgress(progress);
},
[],
);
const handleTimelinePlayingChange = useCallback((playing: boolean) => {
setisTimelapsePlaying(playing)
setisTimelapsePlaying(playing);
if (playing) {
setSelectedDistrict(null)
setSelectedDistrict(null);
}
}, [])
}, []);
const resetFilters = useCallback(() => {
setSelectedYear(2024)
setSelectedMonth("all")
setSelectedCategory("all")
}, [])
setSelectedYear(2024);
setSelectedMonth("all");
setSelectedCategory("all");
}, []);
const getMapTitle = () => {
if (useAllYears) {
return `All Years Data ${selectedCategory !== "all" ? `- ${selectedCategory}` : ''}`;
return `All Years Data ${
selectedCategory !== "all" ? `- ${selectedCategory}` : ""
}`;
}
let title = `${selectedYear}`;
@ -224,39 +289,52 @@ export default function CrimeMap() {
title += ` - ${selectedCategory}`;
}
return title;
}
};
const handleControlChange = (controlId: ITooltipsControl) => {
if (selectedSourceType === "cbu" &&
!["clusters", "reports", "layers", "search", "alerts"].includes(controlId as string)) {
if (
selectedSourceType === "cbu" &&
!["clusters", "reports", "layers", "search", "alerts"].includes(
controlId as string,
)
) {
return;
}
setActiveControl(controlId);
if (controlId === "clusters") {
setShowClusters(true)
setShowClusters(true);
} else {
setShowClusters(false)
setShowClusters(false);
}
if (controlId === "incidents") {
setShowUnclustered(true)
setShowAllIncidents(true);
} else {
setShowUnclustered(false)
setShowAllIncidents(false);
}
if (controlId === "search") {
setIsSearchActive(prev => !prev);
setIsSearchActive((prev) => !prev);
}
if (controlId === "units") {
setShowUnitsLayer(true);
} else if (showUnitsLayer) {
} else {
setShowUnitsLayer(false);
}
if (controlId === "heatmap" || controlId === "timeline") {
if (controlId === "timeline") {
setShowTimelineLayer(true);
} else {
setShowTimelineLayer(false);
}
if (
controlId === "heatmap" || controlId === "timeline" ||
controlId === "incidents"
) {
setUseAllYears(true);
setUseAllMonths(true);
} else {
@ -265,9 +343,9 @@ export default function CrimeMap() {
}
setShowEWS(true);
}
};
const showTimelineLayer = activeControl === "timeline";
// const showTimelineLayer = activeControl === "timeline";
return (
<Card className="w-full p-0 border-none shadow-none h-96">
@ -284,128 +362,164 @@ export default function CrimeMap() {
categories={categories}
isYearsLoading={isYearsLoading}
isCategoryLoading={isCategoryLoading}
disableYearMonth={activeControl === "incidents" ||
activeControl === "heatmap" ||
activeControl === "timeline"}
/>
</CardHeader>
<CardContent className="p-0">
{isCrimesLoading ? (
<div className="flex items-center justify-center h-96">
<Skeleton className="h-full w-full rounded-md" />
</div>
) : crimesError ? (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertCircle className="h-10 w-10 text-destructive" />
<p className="text-center">Failed to load crime data. Please try again later.</p>
<Button onClick={() => window.location.reload()}>Retry</Button>
</div>
) : (
<div className="mapbox-container overlay-bg relative h-[600px]" ref={mapContainerRef}>
<div className={cn(
{isCrimesLoading
? (
<div className="flex items-center justify-center h-96">
<Skeleton className="h-full w-full rounded-md" />
</div>
)
: crimesError
? (
<div className="flex flex-col items-center justify-center h-96 gap-4">
<AlertCircle className="h-10 w-10 text-destructive" />
<p className="text-center">
Failed to load crime data. Please try again
later.
</p>
<Button onClick={() => window.location.reload()}>
Retry
</Button>
</div>
)
: (
<div
className="mapbox-container overlay-bg relative h-[600px]"
ref={mapContainerRef}
>
<div
className={cn(
"transition-all duration-300 ease-in-out",
!sidebarCollapsed && isFullscreen && "ml-[400px]"
)}>
<div className="">
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
<Layers
crimes={filteredCrimes || []}
units={fetchedUnits || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
activeControl={activeControl}
useAllData={useAllYears}
showEWS={showEWS}
recentIncidents={recentIncidents || []}
sourceType={selectedSourceType}
/>
!sidebarCollapsed && isFullscreen &&
"ml-[400px]",
)}
>
<div className="">
<MapView
mapStyle="mapbox://styles/mapbox/dark-v11"
className="h-[600px] w-full rounded-md"
>
<Layers
crimes={filteredCrimes || []}
units={fetchedUnits || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
activeControl={activeControl}
useAllData={useAllYears}
showEWS={showEWS}
recentIncidents={recentIncidents ||
[]}
sourceType={selectedSourceType}
/>
{isFullscreen && (
<>
<div className="absolute flex w-full p-2">
<Tooltips
activeControl={activeControl}
onControlChange={handleControlChange}
selectedSourceType={selectedSourceType}
setSelectedSourceType={handleSourceTypeChange}
availableSourceTypes={availableSourceTypes ||
[]}
selectedYear={selectedYear}
setSelectedYear={setSelectedYear}
selectedMonth={selectedMonth}
setSelectedMonth={setSelectedMonth}
selectedCategory={selectedCategory}
setSelectedCategory={setSelectedCategory}
availableYears={availableYears ||
[]}
categories={categories}
crimes={filteredCrimes}
disableYearMonth={activeControl ===
"incidents" ||
activeControl ===
"heatmap" ||
activeControl ===
"timeline"}
/>
</div>
{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}
{mapboxMap && (
<EWSAlertLayer
map={mapboxMap}
incidents={ewsIncidents}
onIncidentResolved={handleResolveIncident}
/>
)}
{displayPanicDemo && (
<div className="absolute top-0 right-20 z-50 p-2">
<PanicButtonDemo
onTriggerAlert={handleTriggerAlert}
onResolveAllAlerts={handleResolveAllAlerts}
activeIncidents={ewsIncidents
.filter((inc) =>
inc.status ===
"active"
)}
/>
</div>
)}
{mapboxMap && (
<EWSAlertLayer map={mapboxMap} incidents={ewsIncidents} onIncidentResolved={handleResolveIncident} />
)}
{displayPanicDemo && (
<div className="absolute top-0 right-20 z-50 p-2">
<PanicButtonDemo
onTriggerAlert={handleTriggerAlert}
onResolveAllAlerts={handleResolveAllAlerts}
activeIncidents={ewsIncidents.filter((inc) => inc.status === "active")}
/>
</div>
)}
<CrimeSidebar
crimes={filteredCrimes || []}
defaultCollapsed={sidebarCollapsed}
selectedCategory={selectedCategory}
selectedYear={selectedYear}
selectedMonth={selectedMonth}
sourceType={selectedSourceType} // 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}
<CrimeSidebar
crimes={filteredCrimes ||[]}
defaultCollapsed={sidebarCollapsed}
selectedCategory={selectedCategory}
selectedYear={selectedYear}
selectedMonth={selectedMonth}
sourceType={selectedSourceType} // Pass the sourceType
/>
</div>
</MapView>
</div>
<div className="absolute bottom-20 right-0 z-20 p-2">
{showClusters && (
<MapLegend position="bottom-right" />
)}
{!showClusters && (
<MapLegend position="bottom-right" />
)}
</div>
{showUnitsLayer && (
<div className="absolute bottom-20 right-0 z-10 p-2">
<UnitsLegend
categories={categories}
position="bottom-right"
/>
</div>
)}
{showTimelineLayer && (
<div className="absolute flex bottom-20 right-0 z-10 p-2">
<TimelineLegend position="bottom-right" />
</div>
)}
</>
)}
<div className="absolute flex w-full bottom-0">
<CrimeTimelapse
startYear={2020}
endYear={2024}
autoPlay={false}
onChange={handleTimelineChange}
onPlayingChange={handleTimelinePlayingChange}
/>
</div>
</MapView>
</div>
</div>
)}
</div>
</div>
)}
</CardContent>
</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 { 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 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 { useCallback, useEffect, useRef, useState } from "react";
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 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 { IDistrictFeature } from "@/app/_utils/types/map"
import { createFillColorExpression, getCrimeRateColor, processCrimeDataByDistrict } from "@/app/_utils/map"
import UnclusteredPointLayer from "./uncluster-layer"
import type { ICrimes, IIncidentLogs } from "@/app/_utils/types/crimes";
import type { IDistrictFeature } from "@/app/_utils/types/map";
import {
createFillColorExpression,
getCrimeRateColor,
processCrimeDataByDistrict,
} from "@/app/_utils/map";
import { toast } from "sonner"
import type { ITooltipsControl } from "../controls/top/tooltips"
import type { IUnits } from "@/app/_utils/types/units"
import UnitsLayer from "./units-layer"
import DistrictFillLineLayer from "./district-layer"
import { toast } from "sonner";
import type { ITooltipsControl } from "../controls/top/tooltips";
import type { IUnits } from "@/app/_utils/types/units";
import UnitsLayer from "./units-layer";
import DistrictFillLineLayer from "./district-layer";
import TimezoneLayer from "./timezone"
import FaultLinesLayer from "./fault-lines"
import RecentIncidentsLayer from "./recent-incidents-layer"
import IncidentPopup from "../pop-up/incident-popup"
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility"
import TimezoneLayer from "./timezone";
import FaultLinesLayer from "./fault-lines";
import RecentIncidentsLayer from "./recent-incidents-layer";
import IncidentPopup from "../pop-up/incident-popup";
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility";
import AllIncidentsLayer from "./all-incidents-layer";
// Interface for crime incident
interface ICrimeIncident {
id: string
district?: string
category?: string
type_category?: string | null
description?: string
status: string
address?: string | null
timestamp?: Date
latitude?: number
longitude?: number
id: string;
district?: string;
category?: string;
type_category?: string | null;
description?: string;
status: string;
address?: string | null;
timestamp?: Date;
latitude?: number;
longitude?: number;
}
// District layer props
export interface IDistrictLayerProps {
visible?: boolean
onClick?: (feature: IDistrictFeature) => void
onDistrictClick?: (feature: IDistrictFeature) => void
map?: any
year: string
month: string
filterCategory: string | "all"
crimes: ICrimes[]
units?: IUnits[]
tilesetId?: string
focusedDistrictId?: string | null
setFocusedDistrictId?: (id: string | null) => void
crimeDataByDistrict?: Record<string, any>
showFill?: boolean
activeControl?: ITooltipsControl
visible?: boolean;
onClick?: (feature: IDistrictFeature) => void;
onDistrictClick?: (feature: IDistrictFeature) => void;
map?: any;
year: string;
month: string;
filterCategory: string | "all";
crimes: ICrimes[];
units?: IUnits[];
tilesetId?: string;
focusedDistrictId?: string | null;
setFocusedDistrictId?: (id: string | null) => void;
crimeDataByDistrict?: Record<string, any>;
showFill?: boolean;
activeControl?: ITooltipsControl;
}
interface LayersProps {
visible?: boolean
crimes: ICrimes[]
units?: IUnits[]
recentIncidents: IIncidentLogs[]
year: string
month: string
filterCategory: string | "all"
activeControl: ITooltipsControl
tilesetId?: string
useAllData?: boolean
showEWS?: boolean
sourceType?: string
visible?: boolean;
crimes: ICrimes[];
units?: IUnits[];
recentIncidents: IIncidentLogs[];
year: string;
month: string;
filterCategory: string | "all";
activeControl: ITooltipsControl;
tilesetId?: string;
useAllData?: boolean;
showEWS?: boolean;
sourceType?: string;
}
export default function Layers({
@ -88,32 +100,38 @@ export default function Layers({
showEWS = true,
sourceType = "cbt",
}: LayersProps) {
const animationRef = useRef<number | null>(null)
const animationRef = useRef<number | null>(null);
const { current: map } = useMap()
const { current: map } = useMap();
if (!map) {
toast.error("Map not found")
return null
toast.error("Map not found");
return null;
}
const mapboxMap = map.getMap()
const mapboxMap = map.getMap();
const [selectedDistrict, setSelectedDistrict] = useState<IDistrictFeature | null>(null)
const [selectedIncident, setSelectedIncident] = useState<ICrimeIncident | null>(null)
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null)
const selectedDistrictRef = useRef<IDistrictFeature | null>(null)
const [selectedDistrict, setSelectedDistrict] = useState<
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
const isInteractingWithMarker = useRef<boolean>(false)
const isInteractingWithMarker = useRef<boolean>(false);
const crimeDataByDistrict = processCrimeDataByDistrict(crimes)
const crimeDataByDistrict = processCrimeDataByDistrict(crimes);
const handlePopupClose = useCallback(() => {
selectedDistrictRef.current = null
setSelectedDistrict(null)
setSelectedIncident(null)
setFocusedDistrictId(null)
isInteractingWithMarker.current = false
selectedDistrictRef.current = null;
setSelectedDistrict(null);
setSelectedIncident(null);
setFocusedDistrictId(null);
isInteractingWithMarker.current = false;
if (map) {
map.easeTo({
@ -122,113 +140,145 @@ export default function Layers({
bearing: BASE_BEARING,
duration: BASE_DURATION,
easing: (t) => t * (2 - t),
})
});
if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
map.getMap().setLayoutProperty(
"clusters",
"visibility",
"visible",
);
}
if (map.getLayer("unclustered-point")) {
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
map.getMap().setLayoutProperty(
"unclustered-point",
"visibility",
"visible",
);
}
if (map.getLayer("district-fill")) {
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
const fillColorExpression = createFillColorExpression(
null,
crimeDataByDistrict,
);
map.getMap().setPaintProperty(
"district-fill",
"fill-color",
fillColorExpression as any,
);
}
}
}, [map, crimeDataByDistrict])
}, [map, crimeDataByDistrict]);
const animateExtrusionDown = () => {
if (!map || !map.getLayer("district-extrusion") || !focusedDistrictId) {
return
return;
}
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
// Get the current height from the layer (default to 800 if not found)
let currentHeight = 800
let currentHeight = 800;
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) {
// 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") {
currentHeight = paint[idx + 1]
currentHeight = paint[idx + 1];
}
}
} catch {
// fallback to default
}
const startHeight = currentHeight
const targetHeight = 0
const duration = 700
const startTime = performance.now()
const startHeight = currentHeight;
const targetHeight = 0;
const duration = 700;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
const easedProgress = progress * (2 - progress)
const newHeight = startHeight + (targetHeight - startHeight) * easedProgress
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = progress * (2 - progress);
const newHeight = startHeight +
(targetHeight - startHeight) * easedProgress;
try {
map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-height", [
"case",
["has", "kode_kec"],
["match", ["get", "kode_kec"], focusedDistrictId, newHeight, 0],
0,
])
map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-color", [
"case",
["has", "kode_kec"],
map.getMap().setPaintProperty(
"district-extrusion",
"fill-extrusion-height",
[
"match",
["get", "kode_kec"],
focusedDistrictId || "",
"transparent",
"case",
["has", "kode_kec"],
[
"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",
])
);
if (progress < 1) {
animationRef.current = requestAnimationFrame(animate)
animationRef.current = requestAnimationFrame(animate);
} else {
animationRef.current = null
animationRef.current = null;
}
} catch (error) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
}
}
};
animationRef.current = requestAnimationFrame(animate)
}
animationRef.current = requestAnimationFrame(animate);
};
const handleCloseDistrictPopup = useCallback(() => {
animateExtrusionDown()
handlePopupClose()
}, [handlePopupClose, animateExtrusionDown])
animateExtrusionDown();
handlePopupClose();
}, [handlePopupClose, animateExtrusionDown]);
const handleDistrictClick = useCallback(
(feature: IDistrictFeature) => {
if (isInteractingWithMarker.current) {
return
return;
}
setSelectedIncident(null)
setSelectedDistrict(feature)
selectedDistrictRef.current = feature
setFocusedDistrictId(feature.id)
setSelectedIncident(null);
setSelectedDistrict(feature);
selectedDistrictRef.current = feature;
setFocusedDistrictId(feature.id);
if (map && feature.longitude && feature.latitude) {
map.flyTo({
@ -238,27 +288,36 @@ export default function Layers({
bearing: BASE_BEARING,
duration: BASE_DURATION,
easing: (t) => t * (2 - t),
})
});
if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "none")
map.getMap().setLayoutProperty(
"clusters",
"visibility",
"none",
);
}
if (map.getLayer("unclustered-point")) {
map.getMap().setLayoutProperty("unclustered-point", "visibility", "none")
map.getMap().setLayoutProperty(
"unclustered-point",
"visibility",
"none",
);
}
}
},
[map],
)
);
useEffect(() => {
if (!mapboxMap) return
if (!mapboxMap) return;
const handleFlyToEvent = (e: Event) => {
const customEvent = e as CustomEvent
if (!map || !customEvent.detail) return
const customEvent = e as CustomEvent;
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({
center: [longitude, latitude],
@ -266,49 +325,79 @@ export default function Layers({
bearing: bearing || 0,
pitch: pitch || 45,
duration: duration || 2000,
})
}
});
};
mapboxMap.getCanvas().addEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
mapboxMap.getCanvas().addEventListener(
"mapbox_fly_to",
handleFlyToEvent as EventListener,
);
return () => {
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(() => {
if (selectedDistrictRef.current) {
const districtId = selectedDistrictRef.current.id
const districtCrime = crimes.find((crime) => crime.district_id === districtId)
const districtId = selectedDistrictRef.current.id;
const districtCrime = crimes.find((crime) =>
crime.district_id === districtId
);
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) {
demographics = districtCrime.districts.demographics.sort((a, b) => b.year - a.year)[0]
if (
!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
.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) {
console.error("Missing district data:", { demographics, geographics })
return
console.error("Missing district data:", {
demographics,
geographics,
});
return;
}
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) => ({
id: incident.id,
timestamp: incident.timestamp,
@ -319,12 +408,14 @@ export default function Layers({
address: incident.locations.address || "",
latitude: incident.locations.latitude,
longitude: incident.locations.longitude,
}))
}));
const updatedDistrict: IDistrictFeature = {
...selectedDistrictRef.current,
number_of_crime: crimeDataByDistrict[districtId]?.number_of_crime || 0,
level: crimeDataByDistrict[districtId]?.level || selectedDistrictRef.current.level,
number_of_crime:
crimeDataByDistrict[districtId]?.number_of_crime || 0,
level: crimeDataByDistrict[districtId]?.level ||
selectedDistrictRef.current.level,
demographics: {
number_of_unemployed: demographics.number_of_unemployed,
population: demographics.population,
@ -341,60 +432,84 @@ export default function Layers({
crime_incidents,
selectedYear: year,
selectedMonth: month,
}
};
selectedDistrictRef.current = updatedDistrict
selectedDistrictRef.current = updatedDistrict;
setSelectedDistrict((prevDistrict) => {
if (
prevDistrict?.id === updatedDistrict.id &&
prevDistrict?.selectedYear === updatedDistrict.selectedYear &&
prevDistrict?.selectedMonth === updatedDistrict.selectedMonth
prevDistrict?.selectedYear ===
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) => {
if (isMarkerClick) {
isInteractingWithMarker.current = true
const handleSetFocusedDistrictId = useCallback(
(id: string | null, isMarkerClick = false) => {
if (isMarkerClick) {
isInteractingWithMarker.current = true;
setTimeout(() => {
isInteractingWithMarker.current = false
}, 1000)
}
setTimeout(() => {
isInteractingWithMarker.current = false;
}, 1000);
}
setFocusedDistrictId(id)
}, [])
setFocusedDistrictId(id);
},
[],
);
const crimesVisible = activeControl === "incidents"
const showHeatmapLayer = activeControl === "heatmap" && sourceType !== "cbu"
const showUnitsLayer = activeControl === "units"
const showTimelineLayer = activeControl === "timeline"
const showHistoricalLayer = activeControl === "historical"
const showRecentIncidents = activeControl === "recents"
const showDistrictFill =
activeControl === "incidents" ||
const showHeatmapLayer = activeControl === "heatmap" &&
sourceType !== "cbu";
const showUnitsLayer = activeControl === "units";
const showTimelineLayer = activeControl === "timeline";
const showRecentIncidents = activeControl === "recents";
const showAllIncidents = activeControl === "incidents"; // Use the "incidents" tooltip for all incidents
const showDistrictFill = activeControl === "incidents" ||
activeControl === "clusters" ||
activeControl === "historical" ||
activeControl === "recents"
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline" && sourceType !== "cbu"
activeControl === "recents";
const showIncidentMarkers = activeControl !== "heatmap" &&
activeControl !== "timeline" && sourceType !== "cbu";
const shouldShowExtrusion = focusedDistrictId !== null && !isInteractingWithMarker.current
const shouldShowExtrusion = focusedDistrictId !== null &&
!isInteractingWithMarker.current;
useEffect(() => {
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 heatmapLayerIds = ["heatmap-layer"];
const unitsLayerIds = ["units-points", "incidents-points", "units-labels", "units-connection-lines"];
const clusterLayerIds = ["clusters", "cluster-count", "crime-points", "crime-count-labels"];
const unitsLayerIds = [
"units-points",
"incidents-points",
"units-labels",
"units-connection-lines",
];
const clusterLayerIds = [
"clusters",
"cluster-count",
"crime-points",
"crime-count-labels",
];
const unclusteredLayerIds = ["unclustered-point"];
const allIncidentsLayerIds = [
"all-incidents-pulse",
"all-incidents-circles",
"all-incidents",
];
if (activeControl !== "recents") {
manageLayerVisibility(mapboxMap, recentLayerIds, false);
@ -416,10 +531,13 @@ export default function Layers({
manageLayerVisibility(mapboxMap, clusterLayerIds, false);
}
if (activeControl !== "incidents" && activeControl !== "recents" && activeControl !== "historical") {
manageLayerVisibility(mapboxMap, unclusteredLayerIds, false);
if (activeControl !== "incidents") {
manageLayerVisibility(mapboxMap, allIncidentsLayerIds, false);
}
if (activeControl !== "incidents" && activeControl !== "recents") {
manageLayerVisibility(mapboxMap, unclusteredLayerIds, false);
}
}, [activeControl, mapboxMap]);
return (
@ -450,6 +568,13 @@ export default function Layers({
/>
)}
<AllIncidentsLayer
visible={showAllIncidents}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}
/>
<RecentIncidentsLayer
visible={showRecentIncidents}
map={mapboxMap}
@ -496,15 +621,8 @@ export default function Layers({
sourceType={sourceType}
/>
<UnclusteredPointLayer
visible={visible && showIncidentMarkers && !focusedDistrictId}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}
focusedDistrictId={focusedDistrictId}
/>
{selectedDistrict && !selectedIncident && !isInteractingWithMarker.current && (
{selectedDistrict && !selectedIncident &&
!isInteractingWithMarker.current && (
<DistrictPopup
longitude={selectedDistrict.longitude || 0}
latitude={selectedDistrict.latitude || 0}
@ -520,5 +638,5 @@ export default function Layers({
<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
}