From 4590e21c39e7bce3375e9b2f53433d6fe335dd3d Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Thu, 15 May 2025 12:21:24 +0700 Subject: [PATCH] Refactor map layers: consolidate imports, enhance type definitions, and improve layer management. Remove UnclusteredPointLayer and introduce AllIncidentsLayer for better incident handling and visualization. --- .../map/controls/left/sidebar/map-sidebar.tsx | 286 +++++---- .../left/sidebar/tabs/incidents-tab.tsx | 2 +- .../left/sidebar/tabs/statistics-tab.tsx | 2 +- .../_components/map/controls/map-selector.tsx | 8 +- .../map/controls/month-selector.tsx | 41 +- .../map/controls/top/additional-tooltips.tsx | 371 ++++++----- .../map/controls/top/crime-tooltips.tsx | 4 +- .../_components/map/controls/top/tooltips.tsx | 8 +- .../map/controls/year-selector.tsx | 108 ++-- .../app/_components/map/crime-map.tsx | 574 +++++++++++------- .../map/layers/all-incidents-layer.tsx | 429 +++++++++++++ .../map/layers/historical-incidents-layer.tsx | 260 -------- .../app/_components/map/layers/layers.tsx | 524 +++++++++------- .../map/layers/uncluster-layer.tsx | 229 ------- 14 files changed, 1574 insertions(+), 1272 deletions(-) create mode 100644 sigap-website/app/_components/map/layers/all-incidents-layer.tsx delete mode 100644 sigap-website/app/_components/map/layers/historical-incidents-layer.tsx delete mode 100644 sigap-website/app/_components/map/layers/uncluster-layer.tsx diff --git a/sigap-website/app/_components/map/controls/left/sidebar/map-sidebar.tsx b/sigap-website/app/_components/map/controls/left/sidebar/map-sidebar.tsx index 25ebf9d..de7e337 100644 --- a/sigap-website/app/_components/map/controls/left/sidebar/map-sidebar.tsx +++ b/sigap-website/app/_components/map/controls/left/sidebar/map-sidebar.tsx @@ -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(new Date()) - const [location, setLocation] = useState("Jember, East Java") + const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); + const [activeTab, setActiveTab] = useState("incidents"); + const [activeIncidentTab, setActiveIncidentTab] = useState("recent"); + const [currentTime, setCurrentTime] = useState(new Date()); + const [location, setLocation] = useState("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 ( -
+
@@ -159,7 +184,6 @@ export default function CrimeSidebar({ onValueChange={setActiveTab} > -
- {isLoading ? ( -
- -
- - - - + {isLoading + ? ( +
+ +
+ + + + +
+
+ + + +
-
- - - -
-
- ) : ( - <> - - - + + - + /> + - - + - + /> + - - - - - )} + + + + + )}
@@ -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"} >
- ) + ); } diff --git a/sigap-website/app/_components/map/controls/left/sidebar/tabs/incidents-tab.tsx b/sigap-website/app/_components/map/controls/left/sidebar/tabs/incidents-tab.tsx index 770fa1c..1d37414 100644 --- a/sigap-website/app/_components/map/controls/left/sidebar/tabs/incidents-tab.tsx +++ b/sigap-website/app/_components/map/controls/left/sidebar/tabs/incidents-tab.tsx @@ -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; diff --git a/sigap-website/app/_components/map/controls/left/sidebar/tabs/statistics-tab.tsx b/sigap-website/app/_components/map/controls/left/sidebar/tabs/statistics-tab.tsx index 58848e4..103fd54 100644 --- a/sigap-website/app/_components/map/controls/left/sidebar/tabs/statistics-tab.tsx +++ b/sigap-website/app/_components/map/controls/left/sidebar/tabs/statistics-tab.tsx @@ -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[] } diff --git a/sigap-website/app/_components/map/controls/map-selector.tsx b/sigap-website/app/_components/map/controls/map-selector.tsx index 9d0f06a..0ae8819 100644 --- a/sigap-website/app/_components/map/controls/map-selector.tsx +++ b/sigap-website/app/_components/map/controls/map-selector.tsx @@ -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} /> (null) const [isClient, setIsClient] = useState(false) @@ -52,26 +54,27 @@ export default function MonthSelector({
) : ( - + + + + + {includeAllOption && All Months} + {months.map((month) => ( + + {month.label} + + ))} + + )}
) diff --git a/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx b/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx index db8841a..fe28347 100644 --- a/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/additional-tooltips.tsx @@ -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: , label: "Police Report" }, - { id: "recents" as ITooltipsControl, icon: , label: "Recent incidents" }, -] + { + id: "reports" as ITooltipsControl, + icon: , + label: "Police Report", + }, + { + id: "recents" as ITooltipsControl, + icon: , + 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(null) - const [isClient, setIsClient] = useState(false) + const [showSelectors, setShowSelectors] = useState(false); + const containerRef = useRef(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 ( <> -
+
- {additionalTooltips.map((control) => { - const isButtonDisabled = isControlDisabled(control.id) + {additionalTooltips.map((control) => { + const isButtonDisabled = isControlDisabled(control.id); - return ( - - - + + +

+ {isButtonDisabled + ? "Not available for CBU data" + : control.label} +

+
+
+ ); + })} + + + - {control.icon} - {control.label} - - - -

{isButtonDisabled ? "Not available for CBU data" : control.label}

-
-
- ) - })} + + + + +
+
+ + Source: + + +
+
+ + Year: + + +
+
+ + Month: + + +
+
+ + Category: + + +
+
+
+ + +
+
- - - - - - -
-
- Source: - -
-
- Year: - -
-
- Month: - -
-
- Category: - -
-
-
-
-
- -
- - {showSelectors && ( -
- - - - -
- )} - - ) + {showSelectors && ( +
+ + + + +
+ )} + + ); } diff --git a/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx b/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx index c239f8f..414a4da 100644 --- a/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/crime-tooltips.tsx @@ -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: , label: "All Incidents" }, + { id: "incidents" as ITooltipsControl, icon: , label: "All Incidents" }, { id: "heatmap" as ITooltipsControl, icon: , label: "Density Heatmap" }, { id: "units" as ITooltipsControl, icon: , label: "Police Units" }, { id: "clusters" as ITooltipsControl, icon: , label: "Clustered Incidents" }, diff --git a/sigap-website/app/_components/map/controls/top/tooltips.tsx b/sigap-website/app/_components/map/controls/top/tooltips.tsx index ce0af33..f775f7f 100644 --- a/sigap-website/app/_components/map/controls/top/tooltips.tsx +++ b/sigap-website/app/_components/map/controls/top/tooltips.tsx @@ -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(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 */} diff --git a/sigap-website/app/_components/map/controls/year-selector.tsx b/sigap-website/app/_components/map/controls/year-selector.tsx index 14a6632..57eab1f 100644 --- a/sigap-website/app/_components/map/controls/year-selector.tsx +++ b/sigap-website/app/_components/map/controls/year-selector.tsx @@ -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(null); const [isClient, setIsClient] = useState(false); @@ -41,36 +43,54 @@ function YearSelectorUI({ const container = isClient ? document.getElementById("root") : null; return ( -
- {isLoading ? ( -
- -
- ) : ( - + onYearChange( + value === "all" ? "all" : Number(value), + )} + disabled={disabled} + > + + + - {availableYears - ?.filter((year) => year !== null) - .map((year) => ( - + {includeAllOption && ( + All Years + )} + + {availableYears.map((year) => ( + year !== null && ( + {year} - ))} + ) + ))} - )} + )}
); } @@ -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); @@ -123,4 +143,4 @@ export default function YearSelector(props: YearSelectorProps) { // This wrapper allows the component to be used both as a React component // and to help create a MapboxGL control return ; -} \ No newline at end of file +} diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index d628582..4e7aee4 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -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(null) - const [showLegend, setShowLegend] = useState(true) - const [activeControl, setActiveControl] = useState("clusters") - const [selectedSourceType, setSelectedSourceType] = useState("cbu") - const [selectedYear, setSelectedYear] = useState(2024) - const [selectedMonth, setSelectedMonth] = useState("all") - const [selectedCategory, setSelectedCategory] = useState("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(false) - const [useAllMonths, setUseAllMonths] = useState(false) - const [showEWS, setShowEWS] = useState(true) - const [ewsIncidents, setEwsIncidents] = useState([]) - const [showPanicDemo, setShowPanicDemo] = useState(true) - const [displayPanicDemo, setDisplayPanicDemo] = useState(showEWS && showPanicDemo) + const [sidebarCollapsed, setSidebarCollapsed] = useState(true); + const [activeControl, setActiveControl] = useState( + "clusters", + ); + const [selectedDistrict, setSelectedDistrict] = useState< + IDistrictFeature | null + >(null); + const [selectedSourceType, setSelectedSourceType] = useState("cbu"); + const [selectedYear, setSelectedYear] = useState(2024); + const [selectedMonth, setSelectedMonth] = useState("all"); + const [selectedCategory, setSelectedCategory] = useState( + "all", + ); + const [ewsIncidents, setEwsIncidents] = useState([]); - const mapContainerRef = useRef(null) + const [useAllYears, setUseAllYears] = useState(false); + const [useAllMonths, setUseAllMonths] = useState(false); - const { current: mapInstance } = useMap() + const [showAllIncidents, setShowAllIncidents] = useState(false); + const [showLegend, setShowLegend] = useState(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(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(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 ( @@ -284,128 +362,164 @@ export default function CrimeMap() { categories={categories} isYearsLoading={isYearsLoading} isCategoryLoading={isCategoryLoading} + disableYearMonth={activeControl === "incidents" || + activeControl === "heatmap" || + activeControl === "timeline"} /> - {isCrimesLoading ? ( -
- -
- ) : crimesError ? ( -
- -

Failed to load crime data. Please try again later.

- -
- ) : ( -
-
+ +
+ ) + : crimesError + ? ( +
+ +

+ Failed to load crime data. Please try again + later. +

+ +
+ ) + : ( +
+
-
- - + !sidebarCollapsed && isFullscreen && + "ml-[400px]", + )} + > +
+ + + {isFullscreen && ( + <> +
+ +
- {isFullscreen && ( - <> -
- + )} + + {displayPanicDemo && ( +
+ + inc.status === + "active" + )} />
+ )} - {mapboxMap && ( - - )} - {displayPanicDemo && ( -
- inc.status === "active")} - /> -
- )} - - - -
- {showClusters && ( - - )} - {showUnclustered && !showClusters && ( - - )} -
- - - {showUnitsLayer && ( -
- -
- )} - - {showTimelineLayer && ( -
- -
- )} - - )} - - -
- -
- -
+
+ {showClusters && ( + + )} + + {!showClusters && ( + + )} +
+ + {showUnitsLayer && ( +
+ +
+ )} + + {showTimelineLayer && ( +
+ +
+ )} + + )} + +
+ +
+
-
- )} +
+
+ )} - ) + ); } diff --git a/sigap-website/app/_components/map/layers/all-incidents-layer.tsx b/sigap-website/app/_components/map/layers/all-incidents-layer.tsx new file mode 100644 index 0000000..00b890a --- /dev/null +++ b/sigap-website/app/_components/map/layers/all-incidents-layer.tsx @@ -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(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 && ( + + )} + + ); +} diff --git a/sigap-website/app/_components/map/layers/historical-incidents-layer.tsx b/sigap-website/app/_components/map/layers/historical-incidents-layer.tsx deleted file mode 100644 index 0755554..0000000 --- a/sigap-website/app/_components/map/layers/historical-incidents-layer.tsx +++ /dev/null @@ -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>({}); - - // Generate colors for each year from 2020 to current year - useEffect(() => { - const colors: Record = {}; - 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; -} diff --git a/sigap-website/app/_components/map/layers/layers.tsx b/sigap-website/app/_components/map/layers/layers.tsx index 8ac45d4..7916309 100644 --- a/sigap-website/app/_components/map/layers/layers.tsx +++ b/sigap-website/app/_components/map/layers/layers.tsx @@ -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 - 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; + 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(null) + const animationRef = useRef(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(null) - const [selectedIncident, setSelectedIncident] = useState(null) - const [focusedDistrictId, setFocusedDistrictId] = useState(null) - const selectedDistrictRef = useRef(null) + const [selectedDistrict, setSelectedDistrict] = useState< + IDistrictFeature | null + >(null); + const [selectedIncident, setSelectedIncident] = useState< + ICrimeIncident | null + >(null); + const [focusedDistrictId, setFocusedDistrictId] = useState( + null, + ); + const selectedDistrictRef = useRef(null); // Track if we're currently interacting with a marker to prevent district selection - const isInteractingWithMarker = useRef(false) + const isInteractingWithMarker = useRef(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({ /> )} + + - - - {selectedDistrict && !selectedIncident && !isInteractingWithMarker.current && ( + {selectedDistrict && !selectedIncident && + !isInteractingWithMarker.current && ( - ) + ); } diff --git a/sigap-website/app/_components/map/layers/uncluster-layer.tsx b/sigap-website/app/_components/map/layers/uncluster-layer.tsx deleted file mode 100644 index 5a113ff..0000000 --- a/sigap-website/app/_components/map/layers/uncluster-layer.tsx +++ /dev/null @@ -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 -}