From b564e6f330eda41aa8bd924eac8f55eecb6b8771 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Fri, 2 May 2025 02:47:16 +0700 Subject: [PATCH] feat(map): enhance map functionality with new controls and overlays - Added GeolocateControl to the map for user location tracking. - Introduced CategorySelector for filtering crime categories. - Implemented TopNavigation for year, month, and category selection. - Created MapSelectors for managing filters with reset functionality. - Developed CrimeSidebar for displaying incidents, statistics, and reports. - Added SidebarToggle for collapsing and expanding the sidebar. - Prefetch crime data based on selected year and month. - Updated Popover and Skeleton components for better UI experience. - Refactored OverlayControl for improved rendering and cleanup. - Enhanced styling and responsiveness across components. --- .../_hooks/use-prefetch-crimes.tsx | 100 ++++++++ .../crime-overview/_queries/queries.ts | 8 + .../crime-management/crime-overview/action.ts | 49 ++++ .../map/controls/category-selector.tsx | 64 +++++ .../_components/map/controls/map-control.tsx | 61 ----- .../map/controls/map-navigations.tsx | 219 ++++++++++++++++ .../_components/map/controls/map-selector.tsx | 89 +++++++ .../_components/map/controls/map-tooltips.tsx | 35 +++ .../map/controls/month-selector.tsx | 14 +- .../map/controls/year-selector.tsx | 6 +- .../app/_components/map/crime-map.tsx | 171 +++++++------ .../_components/map/layers/district-layer.tsx | 68 ++++- sigap-website/app/_components/map/map.tsx | 17 +- sigap-website/app/_components/map/overlay.tsx | 163 ++++++------ .../_components/map/sidebar/map-sidebar.tsx | 234 ++++++++++++++++++ .../map/sidebar/sidebar-toggle.tsx | 41 +++ sigap-website/app/_components/ui/popover.tsx | 10 +- sigap-website/app/_components/ui/skeleton.tsx | 2 +- sigap-website/app/_utils/common.ts | 28 --- 19 files changed, 1121 insertions(+), 258 deletions(-) create mode 100644 sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes.tsx create mode 100644 sigap-website/app/_components/map/controls/category-selector.tsx create mode 100644 sigap-website/app/_components/map/controls/map-navigations.tsx create mode 100644 sigap-website/app/_components/map/controls/map-selector.tsx create mode 100644 sigap-website/app/_components/map/controls/map-tooltips.tsx create mode 100644 sigap-website/app/_components/map/sidebar/map-sidebar.tsx create mode 100644 sigap-website/app/_components/map/sidebar/sidebar-toggle.tsx diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes.tsx b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes.tsx new file mode 100644 index 0000000..5bd0544 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes.tsx @@ -0,0 +1,100 @@ +"use client" + +import { useEffect, useState, useCallback, useRef } from "react" + +import { useQueryClient } from "@tanstack/react-query" +import { useGetAvailableYears } from "../_queries/queries" +import { getCrimeByYearAndMonth } from "../action" + +type CrimeData = any // Replace with your actual crime data type + +interface PrefetchedCrimeDataResult { + availableYears: (number | null)[] | undefined + isYearsLoading: boolean + yearsError: Error | null + crimes: CrimeData | undefined + isCrimesLoading: boolean + crimesError: Error | null + setSelectedYear: (year: number) => void + setSelectedMonth: (month: number | "all") => void + selectedYear: number + selectedMonth: number | "all" +} + +export function usePrefetchedCrimeData(initialYear: number = 2024, initialMonth: number | "all" = "all"): PrefetchedCrimeDataResult { + const [selectedYear, setSelectedYear] = useState(initialYear) + const [selectedMonth, setSelectedMonth] = useState(initialMonth) + const [prefetchedData, setPrefetchedData] = useState>({}) + const [isPrefetching, setIsPrefetching] = useState(true) + const [prefetchError, setPrefetchError] = useState(null) + + const queryClient = useQueryClient() + + // Get available years + const { data: availableYears, isLoading: isYearsLoading, error: yearsError } = useGetAvailableYears() + + // Track if we've prefetched data + const hasPrefetched = useRef(false) + + // Prefetch all data combinations + useEffect(() => { + const prefetchAllData = async () => { + if (!availableYears || hasPrefetched.current) return + + setIsPrefetching(true) + const dataCache: Record = {} + + try { + // Prefetch data for all years with "all" months + for (const year of availableYears) { + if (year === null) continue + + // Prefetch "all" months for this year + const allMonthsKey = `${year}-all` + const allMonthsData = await getCrimeByYearAndMonth(year, "all") + dataCache[allMonthsKey] = allMonthsData + + // Also prefetch each individual month for this year + for (let month = 1; month <= 12; month++) { + const monthKey = `${year}-${month}` + const monthData = await getCrimeByYearAndMonth(year, month) + dataCache[monthKey] = monthData + + // Pre-populate the React Query cache + queryClient.setQueryData(["crimes", year, month], monthData) + } + + // Pre-populate the React Query cache for "all" months + queryClient.setQueryData(["crimes", year, "all"], allMonthsData) + } + + setPrefetchedData(dataCache) + hasPrefetched.current = true + } catch (error) { + console.error("Error prefetching crime data:", error) + setPrefetchError(error instanceof Error ? error : new Error("Failed to prefetch data")) + } finally { + setIsPrefetching(false) + } + } + + prefetchAllData() + }, [availableYears, queryClient]) + + // Get the current data based on selected filters + const currentKey = `${selectedYear}-${selectedMonth}` + const currentData = prefetchedData[currentKey] + + return { + availableYears, + isYearsLoading, + yearsError, + crimes: currentData, + isCrimesLoading: isPrefetching && !currentData, + crimesError: prefetchError, + setSelectedYear, + setSelectedMonth, + selectedYear, + selectedMonth, + } +} diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries.ts b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries.ts index ef29ab1..d53d385 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { getAvailableYears, getCrimeByYearAndMonth, + getCrimeCategories, getCrimes, } from '../action'; @@ -28,3 +29,10 @@ export const useGetCrimes = () => { queryFn: () => getCrimes(), }); }; + +export const useGetCrimeCategories = () => { + return useQuery({ + queryKey: ['crime-categories'], + queryFn: () => getCrimeCategories(), + }); +}; diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts index 4e7049e..af43479 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action.ts @@ -59,6 +59,55 @@ export async function getAvailableYears() { ); } +export async function getCrimeCategories() { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'Crime Categories', + { recordResponse: true }, + async () => { + try { + const categories = await db.crime_categories.findMany({ + select: { + id: true, + name: true, + type: true, + }, + }); + + return categories; + } catch (err) { + if (err instanceof InputParseError) { + // return { + // error: err.message, + // }; + + throw new InputParseError(err.message); + } + + if (err instanceof AuthenticationError) { + // return { + // error: 'User not found.', + // }; + + throw new AuthenticationError( + 'There was an error with the credentials. Please try again or contact support.' + ); + } + + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + // return { + // error: + // 'An error happened. The developers have been notified. Please try again later.', + // }; + throw new Error( + 'An error happened. The developers have been notified. Please try again later.' + ); + } + } + ); +} + export async function getCrimes() { const instrumentationService = getInjection('IInstrumentationService'); return await instrumentationService.instrumentServerAction( diff --git a/sigap-website/app/_components/map/controls/category-selector.tsx b/sigap-website/app/_components/map/controls/category-selector.tsx new file mode 100644 index 0000000..3e54672 --- /dev/null +++ b/sigap-website/app/_components/map/controls/category-selector.tsx @@ -0,0 +1,64 @@ +"use client" + +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select" +import { useEffect, useRef, useState } from "react" +import { Skeleton } from "../../ui/skeleton" + +interface CategorySelectorProps { + categories: string[] + selectedCategory: string | "all" + onCategoryChange: (category: string | "all") => void + className?: string + includeAllOption?: boolean + isLoading?: boolean +} + +export default function CategorySelector({ + categories, + selectedCategory, + onCategoryChange, + className = "w-[150px]", + includeAllOption = true, + isLoading = false, +}: CategorySelectorProps) { + const containerRef = useRef(null) + const [isClient, setIsClient] = useState(false) + + useEffect(() => { + // This will ensure that the document is only used in the client-side context + setIsClient(true) + }, []) + + const container = isClient ? document.getElementById("root") : null + + return ( +
+ {isLoading ? ( +
+ +
+ ) : ( + + )} +
+ ) +} diff --git a/sigap-website/app/_components/map/controls/map-control.tsx b/sigap-website/app/_components/map/controls/map-control.tsx index 4ba706a..e69de29 100644 --- a/sigap-website/app/_components/map/controls/map-control.tsx +++ b/sigap-website/app/_components/map/controls/map-control.tsx @@ -1,61 +0,0 @@ -"use client" -import { Button } from "@/app/_components/ui/button" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip" -import { - Clock, - AlertTriangle, - Users, - Building, - Skull, -} from "lucide-react" -import { Overlay } from "../overlay" -import { ControlPosition } from "mapbox-gl" -import { IconBriefcaseOff, IconCategory, IconCategoryFilled } from "@tabler/icons-react" - -interface MapMenusProps { - onControlChange: (control: string) => void - activeControl: string - position: ControlPosition -} - -export default function MapMenus({ onControlChange, activeControl, position = "top-left" }: MapMenusProps) { - const menus = [ - { id: "crime-rate", icon: , label: "Crime Rate" }, - { id: "population", icon: , label: "Population" }, - { id: "unemployment", icon: , label: "Unemployment" }, - { id: "alerts", icon: , label: "Alerts" }, - { id: "time", icon: , label: "Time Analysis" }, - { id: "unit", icon: , label: "Unit" }, - { id: "category", icon: , label: "Category" }, - ] - - return ( - -
- - {menus.map((control) => ( - - - - - -

{control.label}

-
-
- ))} -
-
- - ) -} diff --git a/sigap-website/app/_components/map/controls/map-navigations.tsx b/sigap-website/app/_components/map/controls/map-navigations.tsx new file mode 100644 index 0000000..b674ee2 --- /dev/null +++ b/sigap-website/app/_components/map/controls/map-navigations.tsx @@ -0,0 +1,219 @@ +"use client" + +import { useEffect, useRef, useState } from "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 } from "lucide-react" +import YearSelector from "./year-selector" +import MonthSelector from "./month-selector" +import CategorySelector from "./category-selector" + +// Import all the icons we need +import { + AlertTriangle, + Shield, + FileText, + Users, + Map, + BarChart2, + Clock, + Filter, + Search, + RefreshCw, + Layers, + Siren, + BadgeAlert, + FolderOpen, +} from "lucide-react" +import { ITopTooltipsMapId } from "./map-tooltips" + +interface TopNavigationProps { + onControlChange?: (controlId: ITopTooltipsMapId) => void + activeControl?: string + selectedYear: number + setSelectedYear: (year: number) => void + selectedMonth: number | "all" + setSelectedMonth: (month: number | "all") => void + selectedCategory: string | "all" + setSelectedCategory: (category: string | "all") => void + availableYears?: (number | null)[] + categories?: string[] +} + +export default function TopNavigation({ + onControlChange, + activeControl, + selectedYear, + setSelectedYear, + selectedMonth, + setSelectedMonth, + selectedCategory, + setSelectedCategory, + availableYears = [2022, 2023, 2024], + categories = [], +}: TopNavigationProps) { + const [showSelectors, setShowSelectors] = useState(false) + const containerRef = useRef(null) + + const [isClient, setIsClient] = useState(false) + + const container = isClient ? document.getElementById("root") : null + + useEffect(() => { + // This will ensure that the document is only used in the client-side context + setIsClient(true) + }, []) + + // Define the primary crime data controls + const crimeControls = [ + { id: "incidents" as ITopTooltipsMapId, icon: , label: "All Incidents" }, + { id: "heatmap" as ITopTooltipsMapId, icon: , label: "Crime Heatmap" }, + { id: "trends" as ITopTooltipsMapId, icon: , label: "Crime Trends" }, + { id: "patrol" as ITopTooltipsMapId, icon: , label: "Patrol Areas" }, + { id: "clusters" as ITopTooltipsMapId, icon: , label: "Offender Clusters" }, + { id: "timeline" as ITopTooltipsMapId, icon: , label: "Time Analysis" }, + ] + + // Define the additional tools and features + const additionalControls = [ + { id: "refresh" as ITopTooltipsMapId, icon: , label: "Refresh Data" }, + { id: "search" as ITopTooltipsMapId, icon: , label: "Search Cases" }, + { id: "alerts" as ITopTooltipsMapId, icon: , label: "Active Alerts" }, + { id: "layers" as ITopTooltipsMapId, icon: , label: "Map Layers" }, + ] + + const toggleSelectors = () => { + setShowSelectors(!showSelectors) + } + + return ( +
+
+ {/* Main crime controls */} +
+ + {crimeControls.map((control) => ( + + + + + +

{control.label}

+
+
+ ))} +
+
+ + {/* Additional controls */} +
+ + {additionalControls.map((control) => ( + + + + + +

{control.label}

+
+
+ ))} + + {/* Filters button */} + + + + + + +
+
+ Year: + +
+
+ Month: + +
+
+ Category: + +
+
+
+
+
+
+
+
+ + {/* Selectors row - visible when expanded */} + {showSelectors && ( +
+ + + +
+ )} +
+ ) +} diff --git a/sigap-website/app/_components/map/controls/map-selector.tsx b/sigap-website/app/_components/map/controls/map-selector.tsx new file mode 100644 index 0000000..9d0f06a --- /dev/null +++ b/sigap-website/app/_components/map/controls/map-selector.tsx @@ -0,0 +1,89 @@ +"use client" + +import { Button } from "@/app/_components/ui/button" +import { FilterX } from "lucide-react" +import YearSelector from "./year-selector" +import MonthSelector from "./month-selector" +import CategorySelector from "./category-selector" +import { Skeleton } from "../../ui/skeleton" + +interface MapSelectorsProps { + availableYears: (number | null)[] + selectedYear: number + setSelectedYear: (year: number) => void + selectedMonth: number | "all" + setSelectedMonth: (month: number | "all") => void + selectedCategory: string | "all" + setSelectedCategory: (category: string | "all") => void + categories: string[] + isYearsLoading?: boolean + isCategoryLoading?: boolean + className?: string + compact?: boolean +} + +export default function MapSelectors({ + availableYears, + selectedYear, + setSelectedYear, + selectedMonth, + setSelectedMonth, + selectedCategory, + setSelectedCategory, + categories, + isYearsLoading = false, + isCategoryLoading = false, + className = "", + compact = false, +}: MapSelectorsProps) { + const resetFilters = () => { + setSelectedYear(2024) + setSelectedMonth("all") + setSelectedCategory("all") + } + + return ( +
+ + + + + + + {isYearsLoading ? ( +
+ +
+ ) : ( + + )} +
+ ) +} + diff --git a/sigap-website/app/_components/map/controls/map-tooltips.tsx b/sigap-website/app/_components/map/controls/map-tooltips.tsx new file mode 100644 index 0000000..dabe928 --- /dev/null +++ b/sigap-website/app/_components/map/controls/map-tooltips.tsx @@ -0,0 +1,35 @@ +"use client" + +import { ReactNode } from "react" + +// Define the possible control IDs for the crime map +export type ITopTooltipsMapId = + // Crime data views + | "incidents" + | "heatmap" + | "trends" + | "patrol" + | "reports" + | "clusters" + | "timeline" + + // Tools and features + | "refresh" + | "search" + | "alerts" + | "layers" + | "evidence" + | "arrests"; + +// Map tools type definition +export interface IMapTool { + id: ITopTooltipsMapId; + label: string; + icon: ReactNode; + description?: string; +} + +// Default export for future expansion +export default function MapTools() { + return null; +} diff --git a/sigap-website/app/_components/map/controls/month-selector.tsx b/sigap-website/app/_components/map/controls/month-selector.tsx index cf5b125..5743e97 100644 --- a/sigap-website/app/_components/map/controls/month-selector.tsx +++ b/sigap-website/app/_components/map/controls/month-selector.tsx @@ -2,6 +2,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select" import { useEffect, useRef, useState } from "react" +import { Skeleton } from "../../ui/skeleton" // Month options const months = [ @@ -24,13 +25,15 @@ interface MonthSelectorProps { onMonthChange: (month: number | "all") => void className?: string includeAllOption?: boolean + isLoading?: boolean } export default function MonthSelector({ selectedMonth, onMonthChange, className = "w-[120px]", - includeAllOption = true + includeAllOption = true, + isLoading = false, }: MonthSelectorProps) { const containerRef = useRef(null) const [isClient, setIsClient] = useState(false) @@ -44,7 +47,12 @@ export default function MonthSelector({ return (
- onMonthChange(value === "all" ? "all" : Number(value))} > @@ -54,6 +62,7 @@ export default function MonthSelector({ {includeAllOption && All Months} {months.map((month) => ( @@ -63,6 +72,7 @@ export default function MonthSelector({ ))} + )}
) } diff --git a/sigap-website/app/_components/map/controls/year-selector.tsx b/sigap-website/app/_components/map/controls/year-selector.tsx index dd62c74..9d88d48 100644 --- a/sigap-website/app/_components/map/controls/year-selector.tsx +++ b/sigap-website/app/_components/map/controls/year-selector.tsx @@ -3,6 +3,7 @@ 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)[] @@ -42,7 +43,9 @@ function YearSelectorUI({ return (
{isLoading ? ( -
+
+ +
) : (