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.
This commit is contained in:
parent
897a130ff7
commit
b564e6f330
|
@ -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<number>(initialYear)
|
||||
const [selectedMonth, setSelectedMonth] = useState<number | "all">(initialMonth)
|
||||
const [prefetchedData, setPrefetchedData] = useState<Record<string, CrimeData>>({})
|
||||
const [isPrefetching, setIsPrefetching] = useState<boolean>(true)
|
||||
const [prefetchError, setPrefetchError] = useState<Error | null>(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Get available years
|
||||
const { data: availableYears, isLoading: isYearsLoading, error: yearsError } = useGetAvailableYears()
|
||||
|
||||
// Track if we've prefetched data
|
||||
const hasPrefetched = useRef<boolean>(false)
|
||||
|
||||
// Prefetch all data combinations
|
||||
useEffect(() => {
|
||||
const prefetchAllData = async () => {
|
||||
if (!availableYears || hasPrefetched.current) return
|
||||
|
||||
setIsPrefetching(true)
|
||||
const dataCache: Record<string, CrimeData> = {}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div ref={containerRef} className="mapboxgl-category-selector">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-8">
|
||||
<Skeleton className="h-full w-full rounded-md" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) => onCategoryChange(value)}
|
||||
>
|
||||
<SelectTrigger className={className}>
|
||||
<SelectValue placeholder="Crime Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
container={containerRef.current || container || undefined}
|
||||
style={{ zIndex: 2000 }}
|
||||
className={`${className}`}
|
||||
>
|
||||
{includeAllOption && <SelectItem value="all">All Categories</SelectItem>}
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -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: <Skull size={20} />, label: "Crime Rate" },
|
||||
{ id: "population", icon: <Users size={20} />, label: "Population" },
|
||||
{ id: "unemployment", icon: <IconBriefcaseOff size={20} />, label: "Unemployment" },
|
||||
{ id: "alerts", icon: <AlertTriangle size={20} className="text-amber-500" />, label: "Alerts" },
|
||||
{ id: "time", icon: <Clock size={20} />, label: "Time Analysis" },
|
||||
{ id: "unit", icon: <Building size={20} />, label: "Unit" },
|
||||
{ id: "category", icon: <IconCategory size={20} />, label: "Category" },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
<div className="absolute top-0 left-0 z-10 bg-black/75 rounded-md m-2 p-1 flex items-center space-x-1">
|
||||
<TooltipProvider>
|
||||
{menus.map((control) => (
|
||||
<Tooltip key={control.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={activeControl === control.id ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
||||
? "bg-white text-black hover:bg-white/90"
|
||||
: "text-white hover:bg-white/10"
|
||||
}`}
|
||||
onClick={() => onControlChange(control.id)}
|
||||
>
|
||||
{control.icon}
|
||||
<span className="sr-only">{control.label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{control.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
|
@ -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<HTMLDivElement>(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: <AlertTriangle size={20} />, label: "All Incidents" },
|
||||
{ id: "heatmap" as ITopTooltipsMapId, icon: <Map size={20} />, label: "Crime Heatmap" },
|
||||
{ id: "trends" as ITopTooltipsMapId, icon: <BarChart2 size={20} />, label: "Crime Trends" },
|
||||
{ id: "patrol" as ITopTooltipsMapId, icon: <Shield size={20} />, label: "Patrol Areas" },
|
||||
{ id: "clusters" as ITopTooltipsMapId, icon: <Users size={20} />, label: "Offender Clusters" },
|
||||
{ id: "timeline" as ITopTooltipsMapId, icon: <Clock size={20} />, label: "Time Analysis" },
|
||||
]
|
||||
|
||||
// Define the additional tools and features
|
||||
const additionalControls = [
|
||||
{ id: "refresh" as ITopTooltipsMapId, icon: <RefreshCw size={20} />, label: "Refresh Data" },
|
||||
{ id: "search" as ITopTooltipsMapId, icon: <Search size={20} />, label: "Search Cases" },
|
||||
{ id: "alerts" as ITopTooltipsMapId, icon: <Siren size={20} className="text-red-500" />, label: "Active Alerts" },
|
||||
{ id: "layers" as ITopTooltipsMapId, icon: <Layers size={20} />, label: "Map Layers" },
|
||||
]
|
||||
|
||||
const toggleSelectors = () => {
|
||||
setShowSelectors(!showSelectors)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Main crime controls */}
|
||||
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
|
||||
<TooltipProvider>
|
||||
{crimeControls.map((control) => (
|
||||
<Tooltip key={control.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={activeControl === control.id ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
||||
? "bg-white text-black hover:bg-white/90"
|
||||
: "text-white hover:bg-white/10"
|
||||
}`}
|
||||
onClick={() => onControlChange?.(control.id)}
|
||||
>
|
||||
{control.icon}
|
||||
<span className="sr-only">{control.label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{control.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Additional controls */}
|
||||
<div className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
|
||||
<TooltipProvider>
|
||||
{additionalControls.map((control) => (
|
||||
<Tooltip key={control.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={activeControl === control.id ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
||||
? "bg-white text-black hover:bg-white/90"
|
||||
: "text-white hover:bg-white/10"
|
||||
}`}
|
||||
onClick={() => onControlChange?.(control.id)}
|
||||
>
|
||||
{control.icon}
|
||||
<span className="sr-only">{control.label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{control.label}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
|
||||
{/* Filters button */}
|
||||
<Tooltip>
|
||||
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-md text-white hover:bg-white/10"
|
||||
onClick={toggleSelectors}
|
||||
>
|
||||
<ChevronDown size={20} />
|
||||
<span className="sr-only">Filters</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
container={containerRef.current || container || undefined}
|
||||
className="w-auto p-3 bg-black/90 border-gray-700 text-white"
|
||||
align="end"
|
||||
style={{ zIndex: 2000 }}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs w-16">Year:</span>
|
||||
<YearSelector
|
||||
availableYears={availableYears}
|
||||
selectedYear={selectedYear}
|
||||
onYearChange={setSelectedYear}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs w-16">Month:</span>
|
||||
<MonthSelector
|
||||
selectedMonth={selectedMonth}
|
||||
onMonthChange={setSelectedMonth}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs w-16">Category:</span>
|
||||
<CategorySelector
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
className="w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selectors row - visible when expanded */}
|
||||
{showSelectors && (
|
||||
<div className="z-10 bg-background rounded-md p-2 flex items-center gap-2 md:hidden">
|
||||
<YearSelector
|
||||
availableYears={availableYears}
|
||||
selectedYear={selectedYear}
|
||||
onYearChange={setSelectedYear}
|
||||
className="w-[100px]"
|
||||
/>
|
||||
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[100px]" />
|
||||
<CategorySelector
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
className="w-[100px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -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 (
|
||||
<div className={`flex items-center gap-2 ${className} ${compact ? "flex-col" : "flex-row"}`}>
|
||||
<YearSelector
|
||||
availableYears={availableYears}
|
||||
selectedYear={selectedYear}
|
||||
onYearChange={setSelectedYear}
|
||||
isLoading={isYearsLoading}
|
||||
className={compact ? "w-full" : ""}
|
||||
/>
|
||||
|
||||
<MonthSelector
|
||||
selectedMonth={selectedMonth}
|
||||
onMonthChange={setSelectedMonth}
|
||||
isLoading={isYearsLoading}
|
||||
className={compact ? "w-full" : ""}
|
||||
/>
|
||||
|
||||
<CategorySelector
|
||||
categories={categories}
|
||||
selectedCategory={selectedCategory}
|
||||
onCategoryChange={setSelectedCategory}
|
||||
isLoading={isCategoryLoading}
|
||||
className={compact ? "w-full" : ""}
|
||||
/>
|
||||
|
||||
{isYearsLoading ? (
|
||||
<div className="flex items-center justify-center h-8 w-full">
|
||||
<Skeleton className="h-full w-full rounded-md" />
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant={compact ? "secondary" : "ghost"}
|
||||
size={compact ? "sm" : "default"}
|
||||
onClick={resetFilters}
|
||||
disabled={selectedYear === 2024 && selectedMonth === "all" && selectedCategory === "all"}
|
||||
className={compact ? "w-full" : ""}
|
||||
>
|
||||
<FilterX className="h-4 w-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
|
@ -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<HTMLDivElement>(null)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
@ -44,7 +47,12 @@ export default function MonthSelector({
|
|||
|
||||
return (
|
||||
<div ref={containerRef} className="mapboxgl-month-selector">
|
||||
<Select
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-8">
|
||||
<Skeleton className="h-full w-full rounded-md" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedMonth.toString()}
|
||||
onValueChange={(value) => onMonthChange(value === "all" ? "all" : Number(value))}
|
||||
>
|
||||
|
@ -54,6 +62,7 @@ export default function MonthSelector({
|
|||
<SelectContent
|
||||
container={containerRef.current || container || undefined}
|
||||
style={{ zIndex: 2000 }}
|
||||
className={`${className}`}
|
||||
>
|
||||
{includeAllOption && <SelectItem value="all">All Months</SelectItem>}
|
||||
{months.map((month) => (
|
||||
|
@ -63,6 +72,7 @@ export default function MonthSelector({
|
|||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<div ref={containerRef} className="mapboxgl-year-selector">
|
||||
{isLoading ? (
|
||||
<div className={`${className} h-9 rounded-md bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 animate-pulse`} />
|
||||
<div className="flex items-center justify-center h-8">
|
||||
<Skeleton className="h-full w-full rounded-md" />
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedYear.toString()}
|
||||
|
@ -56,6 +59,7 @@ function YearSelectorUI({
|
|||
<SelectContent
|
||||
container={containerRef.current || container || undefined}
|
||||
style={{ zIndex: 2000 }}
|
||||
className={`${className}`}
|
||||
>
|
||||
{availableYears
|
||||
?.filter((year) => year !== null)
|
||||
|
|
|
@ -5,40 +5,84 @@ import { Skeleton } from "@/app/_components/ui/skeleton"
|
|||
import DistrictLayer, { type DistrictFeature } from "./layers/district-layer"
|
||||
import MapView from "./map"
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { AlertCircle, FilterX } from "lucide-react"
|
||||
import { AlertCircle } from "lucide-react"
|
||||
import { getMonthName } from "@/app/_utils/common"
|
||||
import { useCrimeMapHandler } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_handlers/crime-map-handlers"
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
import { useRef, useState, useCallback, useMemo } from "react"
|
||||
import { CrimePopup } from "./pop-up"
|
||||
import type { CrimeIncident } from "./markers/crime-marker"
|
||||
import YearSelector from "./controls/year-selector"
|
||||
import MonthSelector from "./controls/month-selector"
|
||||
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
|
||||
import { Overlay } from "./overlay"
|
||||
import { useGetAvailableYears, useGetCrimeByYearAndMonth } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
|
||||
import MapLegend from "./controls/map-legend"
|
||||
import { usePrefetchedCrimeData } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-prefetch-crimes"
|
||||
import { useGetCrimeCategories } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
|
||||
import { ITopTooltipsMapId } from "./controls/map-tooltips"
|
||||
import MapSelectors from "./controls/map-selector"
|
||||
import TopNavigation from "./controls/map-navigations"
|
||||
import CrimeSidebar from "./sidebar/map-sidebar"
|
||||
import SidebarToggle from "./sidebar/sidebar-toggle"
|
||||
|
||||
export default function CrimeMap() {
|
||||
// Set default year to 2024 instead of "all"
|
||||
const [selectedYear, setSelectedYear] = useState<number>(2024)
|
||||
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
|
||||
// State for sidebar
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
|
||||
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
||||
const [selectedIncident, setSelectedIncident] = useState<CrimeIncident | null>(null)
|
||||
const [showLegend, setShowLegend] = useState<boolean>(true)
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
|
||||
const [activeControl, setActiveControl] = useState<ITopTooltipsMapId>("incidents")
|
||||
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Use the custom fullscreen hook
|
||||
const { isFullscreen } = useFullscreen(mapContainerRef)
|
||||
|
||||
const { data: availableYears, isLoading: isYearsLoading, error: yearsError } = useGetAvailableYears()
|
||||
// Toggle sidebar function
|
||||
const toggleSidebar = useCallback(() => {
|
||||
setSidebarCollapsed(!sidebarCollapsed)
|
||||
}, [sidebarCollapsed])
|
||||
|
||||
const { data: crimes, isLoading: isCrimesLoading, error: isCrimesError, refetch: refetchCrimes } = useGetCrimeByYearAndMonth(selectedYear, selectedMonth)
|
||||
// Use our new prefetched data hook
|
||||
const {
|
||||
availableYears,
|
||||
isYearsLoading,
|
||||
crimes,
|
||||
isCrimesLoading,
|
||||
crimesError,
|
||||
setSelectedYear,
|
||||
setSelectedMonth,
|
||||
selectedYear,
|
||||
selectedMonth,
|
||||
} = usePrefetchedCrimeData()
|
||||
|
||||
// Extract all unique categories
|
||||
const { data: categoriesData, isLoading: isCategoryLoading } = useGetCrimeCategories()
|
||||
|
||||
// Transform categories data to string array
|
||||
const categories = useMemo(() =>
|
||||
categoriesData ? categoriesData.map(category => category.name) : []
|
||||
, [categoriesData])
|
||||
|
||||
// Filter incidents based on selected category
|
||||
const filteredCrimes = useMemo(() => {
|
||||
if (!crimes) return []
|
||||
if (selectedCategory === "all") return crimes
|
||||
|
||||
return crimes.map((district: { incidents: CrimeIncident[], number_of_crime: number }) => ({
|
||||
...district,
|
||||
incidents: district.incidents.filter(incident =>
|
||||
incident.category === selectedCategory
|
||||
),
|
||||
// Update number_of_crime to reflect the filtered count
|
||||
number_of_crime: district.incidents.filter(
|
||||
incident => incident.category === selectedCategory
|
||||
).length
|
||||
}))
|
||||
}, [crimes, selectedCategory])
|
||||
|
||||
// Extract all incidents from all districts for marker display
|
||||
const allIncidents =
|
||||
crimes?.flatMap((district) =>
|
||||
const allIncidents = useMemo(() => {
|
||||
if (!filteredCrimes) return []
|
||||
|
||||
return filteredCrimes.flatMap((district: { incidents: CrimeIncident[] }) =>
|
||||
district.incidents.map((incident) => ({
|
||||
id: incident.id,
|
||||
timestamp: incident.timestamp,
|
||||
|
@ -49,8 +93,9 @@ export default function CrimeMap() {
|
|||
address: incident.address,
|
||||
latitude: incident.latitude,
|
||||
longitude: incident.longitude,
|
||||
})),
|
||||
) || []
|
||||
}))
|
||||
)
|
||||
}, [filteredCrimes])
|
||||
|
||||
// Handle district click
|
||||
const handleDistrictClick = (feature: DistrictFeature) => {
|
||||
|
@ -62,17 +107,12 @@ export default function CrimeMap() {
|
|||
setSelectedIncident(incident)
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
const applyFilters = () => {
|
||||
refetchCrimes()
|
||||
}
|
||||
|
||||
// Reset filters
|
||||
const resetFilters = () => {
|
||||
const resetFilters = useCallback(() => {
|
||||
setSelectedYear(2024)
|
||||
setSelectedMonth("all")
|
||||
refetchCrimes()
|
||||
}
|
||||
setSelectedCategory("all")
|
||||
}, [setSelectedYear, setSelectedMonth])
|
||||
|
||||
// Determine the title based on filters
|
||||
const getMapTitle = () => {
|
||||
|
@ -80,6 +120,9 @@ export default function CrimeMap() {
|
|||
if (selectedMonth !== "all") {
|
||||
title += ` - ${getMonthName(Number(selectedMonth))}`
|
||||
}
|
||||
if (selectedCategory !== "all") {
|
||||
title += ` - ${selectedCategory}`
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
|
@ -87,47 +130,40 @@ export default function CrimeMap() {
|
|||
<Card className="w-full p-0 border-none shadow-none h-96">
|
||||
<CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between">
|
||||
<CardTitle>Crime Map {getMapTitle()}</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Year selector component */}
|
||||
<YearSelector
|
||||
availableYears={availableYears}
|
||||
selectedYear={selectedYear}
|
||||
onYearChange={setSelectedYear}
|
||||
isLoading={isYearsLoading}
|
||||
/>
|
||||
|
||||
{/* Month selector component */}
|
||||
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} />
|
||||
|
||||
<Button variant="ghost" onClick={resetFilters} disabled={selectedYear === 2024 && selectedMonth === "all"}>
|
||||
<FilterX className="h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<MapSelectors
|
||||
availableYears={availableYears || []}
|
||||
selectedYear={selectedYear}
|
||||
setSelectedYear={setSelectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
setSelectedMonth={setSelectedMonth}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
categories={categories}
|
||||
isYearsLoading={isYearsLoading}
|
||||
isCategoryLoading={isCategoryLoading}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{isCrimesLoading ? (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Skeleton className="h-full w-full rounded-md" />
|
||||
</div>
|
||||
) : isCrimesError ? (
|
||||
) : crimesError ? (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||
<p className="text-center">Failed to load crime data. Please try again later.</p>
|
||||
<Button onClick={() => refetchCrimes()}>Retry</Button>
|
||||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-[600px]" ref={mapContainerRef}>
|
||||
<MapView
|
||||
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||
className="h-[600px] w-full rounded-md"
|
||||
>
|
||||
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
|
||||
{/* District Layer with crime data */}
|
||||
<DistrictLayer
|
||||
onClick={handleDistrictClick}
|
||||
crimes={crimes || []}
|
||||
crimes={filteredCrimes || []}
|
||||
year={selectedYear.toString()}
|
||||
month={selectedMonth.toString()}
|
||||
filterCategory={selectedCategory}
|
||||
/>
|
||||
|
||||
{/* Popup for selected incident */}
|
||||
|
@ -140,38 +176,29 @@ export default function CrimeMap() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Components that are only visible in fullscreen mode */}
|
||||
{isFullscreen && (
|
||||
<>
|
||||
<Overlay
|
||||
position="top-left"
|
||||
className="m-2 bg-transparent shadow-none p-0 border-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-md p-0 shadow-lg">
|
||||
<YearSelector
|
||||
availableYears={availableYears}
|
||||
<Overlay position="top" className="m-0 bg-transparent shadow-none p-0 border-none">
|
||||
<div className="flex justify-center">
|
||||
<TopNavigation
|
||||
activeControl={activeControl}
|
||||
onControlChange={setActiveControl}
|
||||
selectedYear={selectedYear}
|
||||
onYearChange={setSelectedYear}
|
||||
isLoading={isYearsLoading}
|
||||
className=" gap-2 text-white"
|
||||
/>
|
||||
<MonthSelector
|
||||
setSelectedYear={setSelectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
onMonthChange={setSelectedMonth}
|
||||
className=" gap-2 hover:bg-red text-white"
|
||||
setSelectedMonth={setSelectedMonth}
|
||||
selectedCategory={selectedCategory}
|
||||
setSelectedCategory={setSelectedCategory}
|
||||
availableYears={availableYears || []}
|
||||
categories={categories}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className=" hover:bg-red text-white"
|
||||
onClick={resetFilters}
|
||||
disabled={selectedYear === 2024 && selectedMonth === "all"}
|
||||
size="sm"
|
||||
>
|
||||
<FilterX className="h-4 w-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</Overlay>
|
||||
|
||||
{/* Sidebar component without overlay */}
|
||||
<CrimeSidebar defaultCollapsed={sidebarCollapsed} />
|
||||
|
||||
<MapLegend position="bottom-right" />
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -22,6 +22,7 @@ export interface DistrictLayerProps {
|
|||
onClick?: (feature: DistrictFeature) => void
|
||||
year?: string
|
||||
month?: string
|
||||
filterCategory?: string | "all"
|
||||
crimes?: Array<{
|
||||
id: string
|
||||
district_name: string
|
||||
|
@ -38,6 +39,7 @@ export default function DistrictLayer({
|
|||
onClick,
|
||||
year,
|
||||
month,
|
||||
filterCategory = "all",
|
||||
crimes = [],
|
||||
tilesetId = MAPBOX_TILESET_ID,
|
||||
}: DistrictLayerProps) {
|
||||
|
@ -268,8 +270,16 @@ export default function DistrictLayer({
|
|||
// Create a source for clustered incident markers
|
||||
if (crimes.length > 0 && !map.getMap().getSource("crime-incidents")) {
|
||||
// Collect all incidents from all districts
|
||||
const allIncidents = crimes.flatMap((crime) =>
|
||||
crime.incidents.map((incident) => ({
|
||||
const allIncidents = crimes.flatMap((crime) => {
|
||||
// Apply category filter if specified
|
||||
let filteredIncidents = crime.incidents;
|
||||
if (filterCategory !== "all") {
|
||||
filteredIncidents = crime.incidents.filter(
|
||||
incident => incident.category === filterCategory
|
||||
);
|
||||
}
|
||||
|
||||
return filteredIncidents.map((incident) => ({
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: incident.id,
|
||||
|
@ -283,8 +293,8 @@ export default function DistrictLayer({
|
|||
type: "Point" as const,
|
||||
coordinates: [incident.longitude, incident.latitude],
|
||||
},
|
||||
})),
|
||||
)
|
||||
}));
|
||||
});
|
||||
|
||||
// Add a clustered GeoJSON source for incidents
|
||||
map.getMap().addSource("crime-incidents", {
|
||||
|
@ -296,7 +306,7 @@ export default function DistrictLayer({
|
|||
cluster: true,
|
||||
clusterMaxZoom: 14,
|
||||
clusterRadius: 50,
|
||||
})
|
||||
});
|
||||
|
||||
// Only add layers if they don't already exist
|
||||
if (!map.getMap().getLayer("clusters")) {
|
||||
|
@ -473,7 +483,7 @@ export default function DistrictLayer({
|
|||
// This prevents the issue of removing default layers
|
||||
}
|
||||
}
|
||||
}, [map, visible, tilesetId, crimes])
|
||||
}, [map, visible, tilesetId, crimes, filterCategory])
|
||||
|
||||
// Update the crime data when it changes
|
||||
useEffect(() => {
|
||||
|
@ -515,6 +525,48 @@ export default function DistrictLayer({
|
|||
}
|
||||
}, [map, crimes])
|
||||
|
||||
// Update the incident data when it changes
|
||||
useEffect(() => {
|
||||
if (!map || !map.getMap().getSource("crime-incidents")) return;
|
||||
|
||||
try {
|
||||
// Get all incidents, filtered by category if needed
|
||||
const allIncidents = crimes.flatMap((crime) => {
|
||||
// Apply category filter if specified
|
||||
let filteredIncidents = crime.incidents;
|
||||
if (filterCategory !== "all") {
|
||||
filteredIncidents = crime.incidents.filter(
|
||||
incident => incident.category === filterCategory
|
||||
);
|
||||
}
|
||||
|
||||
return filteredIncidents.map((incident) => ({
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: incident.id,
|
||||
district: crime.district_name,
|
||||
category: incident.category,
|
||||
incidentType: incident.type,
|
||||
level: crime.level,
|
||||
description: incident.description,
|
||||
},
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: [incident.longitude, incident.latitude],
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// Update the data source
|
||||
(map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
||||
type: "FeatureCollection",
|
||||
features: allIncidents,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating incident data:", error);
|
||||
}
|
||||
}, [map, crimes, filterCategory]);
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
|
@ -536,8 +588,8 @@ export default function DistrictLayer({
|
|||
{hoverInfo.feature.properties.number_of_crime} incidents
|
||||
{hoverInfo.feature.properties.level && (
|
||||
<span className="ml-2 text-xs font-semibold text-gray-500">({hoverInfo.feature.properties.level})</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -1,16 +1,11 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { type ViewState, Map, type MapRef, NavigationControl } from "react-map-gl/mapbox"
|
||||
import { useState, useCallback } from "react"
|
||||
import { type ViewState, Map, type MapRef, NavigationControl, GeolocateControl } from "react-map-gl/mapbox"
|
||||
import { FullscreenControl } from "react-map-gl/mapbox"
|
||||
import { BASE_LATITUDE, BASE_LONGITUDE, BASE_ZOOM, MAPBOX_STYLES, type MapboxStyle } from "@/app/_utils/const/map"
|
||||
import "mapbox-gl/dist/mapbox-gl.css"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { YearSelectorControl } from "./controls/year-selector"
|
||||
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
|
||||
import { CustomControl } from "./controls/example"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface MapViewProps {
|
||||
children?: React.ReactNode
|
||||
|
@ -35,8 +30,6 @@ export default function MapView({
|
|||
onMoveEnd,
|
||||
}: MapViewProps) {
|
||||
const [mapRef, setMapRef] = useState<MapRef | null>(null)
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { isFullscreen } = useFullscreen(mapContainerRef)
|
||||
|
||||
const defaultViewState: Partial<ViewState> = {
|
||||
longitude: BASE_LONGITUDE,
|
||||
|
@ -53,11 +46,11 @@ export default function MapView({
|
|||
onMoveEnd(event.viewState)
|
||||
}
|
||||
},
|
||||
[onMoveEnd]
|
||||
[onMoveEnd],
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={mapContainerRef} className={`relative ${className}`}>
|
||||
<div className={`relative ${className}`}>
|
||||
<div className="flex h-full">
|
||||
<div className="relative flex-grow h-full transition-all duration-300">
|
||||
<Map
|
||||
|
@ -67,9 +60,11 @@ export default function MapView({
|
|||
onMoveEnd={handleMoveEnd}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
attributionControl={false}
|
||||
preserveDrawingBuffer={true} // This helps with fullscreen stability
|
||||
>
|
||||
<FullscreenControl position="top-right" />
|
||||
<NavigationControl position="top-right" showCompass={false} />
|
||||
|
||||
{children}
|
||||
</Map>
|
||||
</div>
|
||||
|
|
|
@ -1,31 +1,36 @@
|
|||
import { IControl, Map } from "mapbox-gl";
|
||||
import { ControlPosition } from "mapbox-gl";
|
||||
import { cloneElement, memo, ReactElement, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useControl } from "react-map-gl/mapbox";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import type { IControl, Map } from "mapbox-gl"
|
||||
import type { ControlPosition } from "mapbox-gl"
|
||||
import { cloneElement, memo, type ReactElement, useEffect, useState, useRef } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { useControl } from "react-map-gl/mapbox"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
|
||||
// Updated props type to include addControl in children props
|
||||
type OverlayProps = {
|
||||
position: ControlPosition;
|
||||
position: ControlPosition
|
||||
children: ReactElement<{
|
||||
map?: Map;
|
||||
addControl?: (control: IControl, position?: ControlPosition) => void;
|
||||
}>;
|
||||
id?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
map?: Map
|
||||
addControl?: (control: IControl, position?: ControlPosition) => void
|
||||
}>
|
||||
id?: string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
// Custom control for overlay
|
||||
class OverlayControl implements IControl {
|
||||
_map: Map | null = null;
|
||||
_container: HTMLElement | null = null;
|
||||
_position: ControlPosition;
|
||||
_id: string;
|
||||
_redraw?: () => void;
|
||||
_className?: string;
|
||||
_style?: React.CSSProperties;
|
||||
_map: Map | null = null
|
||||
_container: HTMLElement | null = null
|
||||
_position: ControlPosition
|
||||
_id: string
|
||||
_redraw?: () => void
|
||||
_className?: string
|
||||
_style?: React.CSSProperties
|
||||
_isDestroyed = false
|
||||
|
||||
constructor({
|
||||
position,
|
||||
|
@ -34,122 +39,138 @@ class OverlayControl implements IControl {
|
|||
className,
|
||||
style,
|
||||
}: {
|
||||
position: ControlPosition;
|
||||
id: string;
|
||||
redraw?: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
position: ControlPosition
|
||||
id: string
|
||||
redraw?: () => void
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}) {
|
||||
this._position = position;
|
||||
this._id = id;
|
||||
this._redraw = redraw;
|
||||
this._className = className;
|
||||
this._style = style;
|
||||
this._position = position
|
||||
this._id = id
|
||||
this._redraw = redraw
|
||||
this._className = className
|
||||
this._style = style
|
||||
}
|
||||
|
||||
onAdd(map: Map) {
|
||||
this._map = map;
|
||||
this._container = document.createElement('div');
|
||||
this._map = map
|
||||
this._container = document.createElement("div")
|
||||
|
||||
// Apply base classes but keep it minimal to avoid layout conflicts
|
||||
this._container.className = `mapboxgl-ctrl ${this._className || ''}`;
|
||||
this._container.id = this._id;
|
||||
this._container.className = `mapboxgl-ctrl ${this._className || ""}`
|
||||
this._container.id = this._id
|
||||
|
||||
// Important: These styles make the overlay adapt to content
|
||||
this._container.style.pointerEvents = 'auto';
|
||||
this._container.style.display = 'inline-block'; // Allow container to size to content
|
||||
this._container.style.maxWidth = 'none'; // Remove any max-width constraints
|
||||
this._container.style.width = 'auto'; // Let width be determined by content
|
||||
this._container.style.height = 'auto'; // Let height be determined by content
|
||||
this._container.style.overflow = 'visible'; // Allow content to overflow if needed
|
||||
this._container.style.pointerEvents = "auto"
|
||||
this._container.style.display = "inline-block" // Allow container to size to content
|
||||
this._container.style.maxWidth = "none" // Remove any max-width constraints
|
||||
this._container.style.width = "auto" // Let width be determined by content
|
||||
this._container.style.height = "auto" // Let height be determined by content
|
||||
this._container.style.overflow = "visible" // Allow content to overflow if needed
|
||||
this._container.style.zIndex = "10" // Ensure it's above other map elements
|
||||
|
||||
// Apply any custom styles passed as props
|
||||
if (this._style) {
|
||||
Object.entries(this._style).forEach(([key, value]) => {
|
||||
// @ts-ignore - dynamically setting style properties
|
||||
this._container.style[key] = value;
|
||||
});
|
||||
this._container.style[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
if (this._redraw) {
|
||||
map.on('move', this._redraw);
|
||||
this._redraw();
|
||||
map.on("move", this._redraw)
|
||||
this._redraw()
|
||||
}
|
||||
|
||||
return this._container;
|
||||
return this._container
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
if (!this._map || !this._container) return;
|
||||
if (!this._map || !this._container || this._isDestroyed) return
|
||||
|
||||
if (this._redraw) {
|
||||
this._map.off('move', this._redraw);
|
||||
this._map.off("move", this._redraw)
|
||||
}
|
||||
|
||||
this._container.remove();
|
||||
this._map = null;
|
||||
this._container.remove()
|
||||
this._map = null
|
||||
this._isDestroyed = true
|
||||
}
|
||||
|
||||
getDefaultPosition() {
|
||||
return this._position;
|
||||
return this._position
|
||||
}
|
||||
|
||||
getMap() {
|
||||
return this._map;
|
||||
return this._map
|
||||
}
|
||||
|
||||
getElement() {
|
||||
return this._container;
|
||||
return this._container
|
||||
}
|
||||
|
||||
// Method to add other controls to the map
|
||||
addControl(control: IControl, position?: ControlPosition) {
|
||||
if (this._map) {
|
||||
this._map.addControl(control, position);
|
||||
this._map.addControl(control, position)
|
||||
}
|
||||
return this;
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
// Enhanced Overlay component
|
||||
function _Overlay({ position, children, id = `overlay-${uuidv4()}`, className, style }: OverlayProps) {
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null);
|
||||
const [map, setMap] = useState<Map | null>(null);
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null)
|
||||
const [map, setMap] = useState<Map | null>(null)
|
||||
const controlRef = useRef<OverlayControl | null>(null)
|
||||
|
||||
// Use useControl with unique ID to avoid conflicts
|
||||
const ctrl = useControl<OverlayControl>(
|
||||
() =>
|
||||
new OverlayControl({
|
||||
() => {
|
||||
const control = new OverlayControl({
|
||||
position,
|
||||
id,
|
||||
className,
|
||||
style,
|
||||
}),
|
||||
{ position }
|
||||
);
|
||||
})
|
||||
controlRef.current = control
|
||||
return control
|
||||
},
|
||||
{ position },
|
||||
)
|
||||
|
||||
// Update container and map instance when control is ready
|
||||
useEffect(() => {
|
||||
if (ctrl) {
|
||||
setContainer(ctrl.getElement());
|
||||
setMap(ctrl.getMap());
|
||||
setContainer(ctrl.getElement())
|
||||
setMap(ctrl.getMap())
|
||||
}
|
||||
}, [ctrl]);
|
||||
|
||||
// Cleanup function to ensure proper removal
|
||||
return () => {
|
||||
if (controlRef.current && !controlRef.current._isDestroyed) {
|
||||
controlRef.current.onRemove()
|
||||
}
|
||||
}
|
||||
}, [ctrl])
|
||||
|
||||
// Only render if container is ready
|
||||
if (!container || !map) return null;
|
||||
if (!container || !map) return null
|
||||
|
||||
// Use createPortal to render children to container and pass addControl method
|
||||
// return createPortal(
|
||||
// cloneElement(children, { map, addControl: ctrl.addControl.bind(ctrl) }),
|
||||
// container
|
||||
// );
|
||||
// cloneElement(children, {
|
||||
// map,
|
||||
// addControl: (control: IControl, position?: ControlPosition) => ctrl.addControl(control, position),
|
||||
// }),
|
||||
// container,
|
||||
// )
|
||||
|
||||
return createPortal(
|
||||
cloneElement(children, { map }),
|
||||
container
|
||||
container,
|
||||
)
|
||||
}
|
||||
|
||||
// Export as memoized component
|
||||
export const Overlay = memo(_Overlay);
|
||||
export const Overlay = memo(_Overlay)
|
||||
|
|
|
@ -0,0 +1,234 @@
|
|||
"use client"
|
||||
|
||||
import React, { useState } from "react"
|
||||
import { AlertTriangle, BarChart, ChevronRight, MapPin, Skull, Shield, FileText } from "lucide-react"
|
||||
import { Separator } from "@/app/_components/ui/separator"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
|
||||
import { cn } from "@/app/_lib/utils"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
|
||||
|
||||
interface CrimeSidebarProps {
|
||||
className?: string
|
||||
defaultCollapsed?: boolean
|
||||
}
|
||||
|
||||
export default function CrimeSidebar({ className, defaultCollapsed = true }: CrimeSidebarProps) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
|
||||
const [activeTab, setActiveTab] = useState("incidents")
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"absolute top-0 left-0 h-full z-10 transition-all duration-300 ease-in-out bg-background backdrop-blur-sm border-r border-white/10 ",
|
||||
isCollapsed ? "translate-x-[-100%]" : "translate-x-0",
|
||||
className
|
||||
)}>
|
||||
<div className="relative h-full flex items-stretch">
|
||||
{/* Main Sidebar Content */}
|
||||
<div className="bg-background backdrop-blur-sm border-r border-white/10 h-full w-[320px]">
|
||||
<div className="p-4 text-white h-full flex flex-col">
|
||||
<CardHeader className="p-0 pb-2">
|
||||
<CardTitle className="text-xl font-semibold flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Crime Analysis
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<Tabs defaultValue="incidents" className="w-full" value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full mb-2 bg-black/30">
|
||||
<TabsTrigger value="incidents" className="flex-1">Incidents</TabsTrigger>
|
||||
<TabsTrigger value="statistics" className="flex-1">Statistics</TabsTrigger>
|
||||
<TabsTrigger value="reports" className="flex-1">Reports</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1 max-h-[calc(100vh-10rem)]">
|
||||
<TabsContent value="incidents" className="m-0 p-0">
|
||||
<SidebarSection title="Recent Incidents" icon={<AlertTriangle className="h-4 w-4 text-red-400" />}>
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<IncidentCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
</SidebarSection>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="statistics" className="m-0 p-0">
|
||||
<SidebarSection title="Crime Overview" icon={<BarChart className="h-4 w-4 text-blue-400" />}>
|
||||
<div className="space-y-2">
|
||||
<StatCard title="Total Incidents" value="254" change="+12%" />
|
||||
<StatCard title="Hot Zones" value="6" change="+2" />
|
||||
<StatCard title="Case Clearance" value="68%" change="+5%" isPositive />
|
||||
</div>
|
||||
</SidebarSection>
|
||||
|
||||
<Separator className="bg-white/20 my-4" />
|
||||
|
||||
<SidebarSection title="Most Reported" icon={<Skull className="h-4 w-4 text-amber-400" />}>
|
||||
<div className="space-y-2">
|
||||
<CrimeTypeCard type="Theft" count={42} percentage={23} />
|
||||
<CrimeTypeCard type="Assault" count={28} percentage={15} />
|
||||
<CrimeTypeCard type="Vandalism" count={19} percentage={10} />
|
||||
<CrimeTypeCard type="Burglary" count={15} percentage={8} />
|
||||
</div>
|
||||
</SidebarSection>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="reports" className="m-0 p-0">
|
||||
<SidebarSection title="Recent Reports" icon={<FileText className="h-4 w-4 text-indigo-400" />}>
|
||||
<div className="space-y-2">
|
||||
<ReportCard
|
||||
title="Monthly Crime Summary"
|
||||
date="June 15, 2024"
|
||||
author="Dept. Analysis Team"
|
||||
/>
|
||||
<ReportCard
|
||||
title="High Risk Areas Analysis"
|
||||
date="June 12, 2024"
|
||||
author="Regional Coordinator"
|
||||
/>
|
||||
<ReportCard
|
||||
title="Case Resolution Statistics"
|
||||
date="June 10, 2024"
|
||||
author="Investigation Unit"
|
||||
/>
|
||||
<ReportCard
|
||||
title="Quarterly Report Q2 2024"
|
||||
date="June 1, 2024"
|
||||
author="Crime Analysis Department"
|
||||
/>
|
||||
</div>
|
||||
</SidebarSection>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle Button - always visible and positioned correctly */}
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className={cn(
|
||||
"absolute h-12 w-8 bg-background backdrop-blur-sm border-t border-b border-r border-white/10 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-[320px] rounded-r-md",
|
||||
)}
|
||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("h-5 w-5 text-white/80 transition-transform",
|
||||
!isCollapsed && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Helper components for sidebar content
|
||||
|
||||
interface SidebarSectionProps {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
function SidebarSection({ title, children, icon }: SidebarSectionProps) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-white/80 mb-2 flex items-center gap-1.5">
|
||||
{icon}
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IncidentCard() {
|
||||
return (
|
||||
<Card className="bg-white/10 border-0 text-white shadow-none">
|
||||
<CardContent className="p-3 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">Theft reported at Jalan Srikandi</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-white/60">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>Jombang District</span>
|
||||
</div>
|
||||
<div className="mt-1 text-white/60">3 hours ago</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string
|
||||
change: string
|
||||
isPositive?: boolean
|
||||
}
|
||||
|
||||
function StatCard({ title, value, change, isPositive = false }: StatCardProps) {
|
||||
return (
|
||||
<Card className="bg-white/10 border-0 text-white shadow-none">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-white/70">{title}</span>
|
||||
<span className={`text-xs ${isPositive ? "text-green-400" : "text-red-400"}`}>{change}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold mt-1">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface CrimeTypeCardProps {
|
||||
type: string
|
||||
count: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
function CrimeTypeCard({ type, count, percentage }: CrimeTypeCardProps) {
|
||||
return (
|
||||
<Card className="bg-white/10 border-0 text-white shadow-none">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">{type}</span>
|
||||
<span className="text-sm text-white/70">{count} cases</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 bg-white/20 rounded-full overflow-hidden">
|
||||
<div className="bg-blue-500 h-full rounded-full" style={{ width: `${percentage}%` }}></div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-white/70 text-right">{percentage}%</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface ReportCardProps {
|
||||
title: string
|
||||
date: string
|
||||
author: string
|
||||
}
|
||||
|
||||
function ReportCard({ title, date, author }: ReportCardProps) {
|
||||
return (
|
||||
<Card className="bg-white/10 border-0 text-white shadow-none">
|
||||
<CardContent className="p-3 text-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="h-4 w-4 text-indigo-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium">{title}</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-white/60">
|
||||
<Shield className="h-3 w-3" />
|
||||
<span>{author}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-white/60">{date}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { cn } from "@/app/_lib/utils"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { Overlay } from "../overlay"
|
||||
import type { ControlPosition } from "mapbox-gl"
|
||||
|
||||
interface SidebarToggleProps {
|
||||
isCollapsed: boolean
|
||||
onClick: () => void
|
||||
className?: string
|
||||
position?: ControlPosition
|
||||
}
|
||||
|
||||
export default function SidebarToggle({
|
||||
isCollapsed,
|
||||
onClick,
|
||||
className,
|
||||
position = "left"
|
||||
}: SidebarToggleProps) {
|
||||
return (
|
||||
<Overlay position={position}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"absolute z-10 shadow-md h-8 w-8 bg-background border"
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</Overlay>
|
||||
)
|
||||
}
|
|
@ -9,11 +9,15 @@ const Popover = PopoverPrimitive.Root
|
|||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
interface PopoverContentProps extends React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> {
|
||||
container?: HTMLElement
|
||||
}
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
PopoverContentProps
|
||||
>(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal container={container}>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
|
|
|
@ -6,7 +6,7 @@ function Skeleton({
|
|||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
||||
className={cn("animate-pulse rounded-md bg-muted/50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
|
@ -770,31 +770,3 @@ export const getDistrictName = (districtId: string): string => {
|
|||
'Unknown District'
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
// This is a simplified version of
|
||||
// https://github.com/facebook/react/blob/4131af3e4bf52f3a003537ec95a1655147c81270/src/renderers/dom/shared/CSSPropertyOperations.js#L62
|
||||
const unitlessNumber =
|
||||
/box|flex|grid|column|lineHeight|fontWeight|opacity|order|tabSize|zIndex/;
|
||||
|
||||
export function CApplyReactStyle(
|
||||
element: HTMLElement,
|
||||
styles: React.CSSProperties
|
||||
) {
|
||||
if (!element || !styles) {
|
||||
return;
|
||||
}
|
||||
const style = element.style;
|
||||
|
||||
for (const key in styles) {
|
||||
const value = styles[key as keyof React.CSSProperties];
|
||||
if (value !== undefined) {
|
||||
if (Number.isFinite(value) && !unitlessNumber.test(key)) {
|
||||
style[key as any] = `${value}px`;
|
||||
} else {
|
||||
style[key as any] = value as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue