Refactor map layers: consolidate imports, enhance type definitions, and improve layer management. Remove UnclusteredPointLayer and introduce AllIncidentsLayer for better incident handling and visualization.
This commit is contained in:
parent
952bdbed8f
commit
4590e21c39
|
@ -1,33 +1,50 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react"
|
import React, { useEffect, useState } from "react";
|
||||||
import { AlertTriangle, Calendar, Clock, MapPin, X, ChevronRight } from 'lucide-react'
|
import {
|
||||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/app/_components/ui/card"
|
AlertTriangle,
|
||||||
import { cn } from "@/app/_lib/utils"
|
Calendar,
|
||||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/app/_components/ui/tabs"
|
ChevronRight,
|
||||||
import { Button } from "@/app/_components/ui/button"
|
Clock,
|
||||||
import { Skeleton } from "@/app/_components/ui/skeleton"
|
MapPin,
|
||||||
import { useMap } from "react-map-gl/mapbox"
|
X,
|
||||||
import { ICrimes } from "@/app/_utils/types/crimes"
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/app/_components/ui/card";
|
||||||
|
import { cn } from "@/app/_lib/utils";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@/app/_components/ui/tabs";
|
||||||
|
import { Button } from "@/app/_components/ui/button";
|
||||||
|
import { Skeleton } from "@/app/_components/ui/skeleton";
|
||||||
|
import { useMap } from "react-map-gl/mapbox";
|
||||||
|
import { ICrimes } from "@/app/_utils/types/crimes";
|
||||||
|
|
||||||
// Import sidebar components
|
// Import sidebar components
|
||||||
import { SidebarIncidentsTab } from "./tabs/incidents-tab"
|
import { SidebarIncidentsTab } from "./tabs/incidents-tab";
|
||||||
|
|
||||||
import { getMonthName } from "@/app/_utils/common"
|
import { getMonthName } from "@/app/_utils/common";
|
||||||
import { SidebarInfoTab } from "./tabs/info-tab"
|
import { SidebarInfoTab } from "./tabs/info-tab";
|
||||||
import { SidebarStatisticsTab } from "./tabs/statistics-tab"
|
import { SidebarStatisticsTab } from "./tabs/statistics-tab";
|
||||||
import { useCrimeAnalytics } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics"
|
import { useCrimeAnalytics } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_hooks/use-crime-analytics";
|
||||||
import { usePagination } from "@/app/_hooks/use-pagination"
|
import { usePagination } from "@/app/_hooks/use-pagination";
|
||||||
|
|
||||||
interface CrimeSidebarProps {
|
interface CrimeSidebarProps {
|
||||||
className?: string
|
className?: string;
|
||||||
defaultCollapsed?: boolean
|
defaultCollapsed?: boolean;
|
||||||
selectedCategory?: string | "all"
|
selectedCategory?: string | "all";
|
||||||
selectedYear: number
|
selectedYear: number | "all";
|
||||||
selectedMonth?: number | "all"
|
selectedMonth?: number | "all";
|
||||||
crimes: ICrimes[]
|
crimes: ICrimes[];
|
||||||
isLoading?: boolean
|
isLoading?: boolean;
|
||||||
sourceType?: string
|
sourceType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CrimeSidebar({
|
export default function CrimeSidebar({
|
||||||
|
@ -40,81 +57,89 @@ export default function CrimeSidebar({
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
sourceType = "cbt",
|
sourceType = "cbt",
|
||||||
}: CrimeSidebarProps) {
|
}: CrimeSidebarProps) {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed)
|
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
|
||||||
const [activeTab, setActiveTab] = useState("incidents")
|
const [activeTab, setActiveTab] = useState("incidents");
|
||||||
const [activeIncidentTab, setActiveIncidentTab] = useState("recent")
|
const [activeIncidentTab, setActiveIncidentTab] = useState("recent");
|
||||||
const [currentTime, setCurrentTime] = useState<Date>(new Date())
|
const [currentTime, setCurrentTime] = useState<Date>(new Date());
|
||||||
const [location, setLocation] = useState<string>("Jember, East Java")
|
const [location, setLocation] = useState<string>("Jember, East Java");
|
||||||
|
|
||||||
// Get the map instance to use for flyTo
|
// Get the map instance to use for flyTo
|
||||||
const { current: map } = useMap()
|
const { current: map } = useMap();
|
||||||
|
|
||||||
// Use custom hooks for analytics and pagination
|
// Use custom hooks for analytics and pagination
|
||||||
const crimeStats = useCrimeAnalytics(crimes)
|
const crimeStats = useCrimeAnalytics(crimes);
|
||||||
const { paginationState, handlePageChange } = usePagination(crimeStats.availableMonths)
|
const { paginationState, handlePageChange } = usePagination(
|
||||||
|
crimeStats.availableMonths,
|
||||||
|
);
|
||||||
|
|
||||||
// Update current time every minute for the real-time display
|
// Update current time every minute for the real-time display
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCurrentTime(new Date())
|
setCurrentTime(new Date());
|
||||||
}, 60000)
|
}, 60000);
|
||||||
|
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
// Set default tab based on source type
|
// Set default tab based on source type
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sourceType === "cbu") {
|
if (sourceType === "cbu") {
|
||||||
setActiveTab("incidents")
|
setActiveTab("incidents");
|
||||||
}
|
}
|
||||||
}, [sourceType])
|
}, [sourceType]);
|
||||||
|
|
||||||
// Format date with selected year and month if provided
|
// Format date with selected year and month if provided
|
||||||
const getDisplayDate = () => {
|
const getDisplayDate = () => {
|
||||||
if (selectedMonth && selectedMonth !== 'all') {
|
if (selectedMonth && selectedMonth !== "all") {
|
||||||
const date = new Date()
|
const date = new Date();
|
||||||
date.setFullYear(selectedYear)
|
date.setFullYear(
|
||||||
date.setMonth(Number(selectedMonth) - 1)
|
typeof selectedYear === "number"
|
||||||
|
? selectedYear
|
||||||
|
: new Date().getFullYear(),
|
||||||
|
);
|
||||||
|
date.setMonth(Number(selectedMonth) - 1);
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
month: 'long'
|
month: "long",
|
||||||
}).format(date)
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
weekday: 'long',
|
weekday: "long",
|
||||||
year: 'numeric',
|
year: "numeric",
|
||||||
month: 'long',
|
month: "long",
|
||||||
day: 'numeric'
|
day: "numeric",
|
||||||
}).format(currentTime)
|
}).format(currentTime);
|
||||||
}
|
};
|
||||||
|
|
||||||
const formattedDate = getDisplayDate()
|
const formattedDate = getDisplayDate();
|
||||||
|
|
||||||
const formattedTime = new Intl.DateTimeFormat('en-US', {
|
const formattedTime = new Intl.DateTimeFormat("en-US", {
|
||||||
hour: '2-digit',
|
hour: "2-digit",
|
||||||
minute: '2-digit',
|
minute: "2-digit",
|
||||||
hour12: true
|
hour12: true,
|
||||||
}).format(currentTime)
|
}).format(currentTime);
|
||||||
|
|
||||||
const getTimePeriodDisplay = () => {
|
const getTimePeriodDisplay = () => {
|
||||||
if (selectedMonth && selectedMonth !== 'all') {
|
if (selectedMonth && selectedMonth !== "all") {
|
||||||
return `${getMonthName(Number(selectedMonth))} ${selectedYear}`
|
return `${getMonthName(Number(selectedMonth))} ${selectedYear}`;
|
||||||
}
|
|
||||||
return `${selectedYear} - All months`
|
|
||||||
}
|
}
|
||||||
|
return `${selectedYear} - All months`;
|
||||||
|
};
|
||||||
|
|
||||||
const handleIncidentClick = (incident: any) => {
|
const handleIncidentClick = (incident: any) => {
|
||||||
if (!map || !incident.longitude || !incident.latitude) return
|
if (!map || !incident.longitude || !incident.latitude) return;
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div
|
||||||
|
className={cn(
|
||||||
"fixed top-0 left-0 h-full z-40 transition-transform duration-300 ease-in-out bg-background border-r border-sidebar-border",
|
"fixed top-0 left-0 h-full z-40 transition-transform duration-300 ease-in-out bg-background border-r border-sidebar-border",
|
||||||
isCollapsed ? "-translate-x-full" : "translate-x-0",
|
isCollapsed ? "-translate-x-full" : "translate-x-0",
|
||||||
className
|
className,
|
||||||
)}>
|
)}
|
||||||
|
>
|
||||||
<div className="relative h-full flex items-stretch">
|
<div className="relative h-full flex items-stretch">
|
||||||
<div className="bg-background backdrop-blur-sm border-r border-sidebar-border h-full w-[420px]">
|
<div className="bg-background backdrop-blur-sm border-r border-sidebar-border h-full w-[420px]">
|
||||||
<div className="p-4 text-sidebar-foreground h-full flex flex-col max-h-full overflow-hidden">
|
<div className="p-4 text-sidebar-foreground h-full flex flex-col max-h-full overflow-hidden">
|
||||||
|
@ -159,7 +184,6 @@ export default function CrimeSidebar({
|
||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
>
|
>
|
||||||
<TabsList className="w-full mb-4 bg-sidebar-accent p-1 rounded-full">
|
<TabsList className="w-full mb-4 bg-sidebar-accent p-1 rounded-full">
|
||||||
|
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="incidents"
|
value="incidents"
|
||||||
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
|
className="flex-1 rounded-full data-[state=active]:bg-sidebar-primary data-[state=active]:text-sidebar-primary-foreground"
|
||||||
|
@ -182,7 +206,8 @@ export default function CrimeSidebar({
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1 custom-scrollbar">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden pr-1 custom-scrollbar">
|
||||||
{isLoading ? (
|
{isLoading
|
||||||
|
? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Skeleton className="h-24 w-full" />
|
<Skeleton className="h-24 w-full" />
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
@ -197,10 +222,13 @@ export default function CrimeSidebar({
|
||||||
<Skeleton className="h-20 w-full" />
|
<Skeleton className="h-20 w-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)
|
||||||
|
: (
|
||||||
<>
|
<>
|
||||||
|
<TabsContent
|
||||||
<TabsContent value="incidents" className="m-0 p-0 space-y-4">
|
value="incidents"
|
||||||
|
className="m-0 p-0 space-y-4"
|
||||||
|
>
|
||||||
<SidebarIncidentsTab
|
<SidebarIncidentsTab
|
||||||
crimeStats={crimeStats}
|
crimeStats={crimeStats}
|
||||||
formattedDate={formattedDate}
|
formattedDate={formattedDate}
|
||||||
|
@ -220,7 +248,10 @@ export default function CrimeSidebar({
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="statistics" className="m-0 p-0 space-y-4">
|
<TabsContent
|
||||||
|
value="statistics"
|
||||||
|
className="m-0 p-0 space-y-4"
|
||||||
|
>
|
||||||
<SidebarStatisticsTab
|
<SidebarStatisticsTab
|
||||||
crimeStats={crimeStats}
|
crimeStats={crimeStats}
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
|
@ -230,8 +261,13 @@ export default function CrimeSidebar({
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="info" className="m-0 p-0 space-y-4">
|
<TabsContent
|
||||||
<SidebarInfoTab sourceType={sourceType} />
|
value="info"
|
||||||
|
className="m-0 p-0 space-y-4"
|
||||||
|
>
|
||||||
|
<SidebarInfoTab
|
||||||
|
sourceType={sourceType}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -245,16 +281,22 @@ export default function CrimeSidebar({
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute h-12 w-8 bg-background border-t border-b border-r border-sidebar-primary-foreground/30 flex items-center justify-center",
|
"absolute h-12 w-8 bg-background border-t border-b border-r border-sidebar-primary-foreground/30 flex items-center justify-center",
|
||||||
"top-1/2 -translate-y-1/2 transition-all duration-300 ease-in-out",
|
"top-1/2 -translate-y-1/2 transition-all duration-300 ease-in-out",
|
||||||
isCollapsed ? "-right-8 rounded-r-md" : "left-[420px] rounded-r-md",
|
isCollapsed
|
||||||
|
? "-right-8 rounded-r-md"
|
||||||
|
: "left-[420px] rounded-r-md",
|
||||||
)}
|
)}
|
||||||
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
|
aria-label={isCollapsed
|
||||||
|
? "Expand sidebar"
|
||||||
|
: "Collapse sidebar"}
|
||||||
>
|
>
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
className={cn("h-5 w-5 text-sidebar-primary-foreground transition-transform",
|
className={cn(
|
||||||
!isCollapsed && "rotate-180")}
|
"h-5 w-5 text-sidebar-primary-foreground transition-transform",
|
||||||
|
!isCollapsed && "rotate-180",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,7 +30,7 @@ interface SidebarIncidentsTabProps {
|
||||||
formattedTime: string;
|
formattedTime: string;
|
||||||
location: string;
|
location: string;
|
||||||
selectedMonth?: number | "all";
|
selectedMonth?: number | "all";
|
||||||
selectedYear: number;
|
selectedYear: number | "all";
|
||||||
selectedCategory: string | "all";
|
selectedCategory: string | "all";
|
||||||
getTimePeriodDisplay: () => string;
|
getTimePeriodDisplay: () => string;
|
||||||
paginationState: Record<string, number>;
|
paginationState: Record<string, number>;
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { Button } from "@/app/_components/ui/button"
|
||||||
interface ISidebarStatisticsTabProps {
|
interface ISidebarStatisticsTabProps {
|
||||||
crimeStats: ICrimeAnalytics
|
crimeStats: ICrimeAnalytics
|
||||||
selectedMonth?: number | "all"
|
selectedMonth?: number | "all"
|
||||||
selectedYear: number
|
selectedYear: number | "all"
|
||||||
sourceType?: string
|
sourceType?: string
|
||||||
crimes?: ICrimes[]
|
crimes?: ICrimes[]
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,8 +9,8 @@ import { Skeleton } from "../../ui/skeleton"
|
||||||
|
|
||||||
interface MapSelectorsProps {
|
interface MapSelectorsProps {
|
||||||
availableYears: (number | null)[]
|
availableYears: (number | null)[]
|
||||||
selectedYear: number
|
selectedYear: number | "all"
|
||||||
setSelectedYear: (year: number) => void
|
setSelectedYear: (year: number | "all") => void
|
||||||
selectedMonth: number | "all"
|
selectedMonth: number | "all"
|
||||||
setSelectedMonth: (month: number | "all") => void
|
setSelectedMonth: (month: number | "all") => void
|
||||||
selectedCategory: string | "all"
|
selectedCategory: string | "all"
|
||||||
|
@ -20,6 +20,7 @@ interface MapSelectorsProps {
|
||||||
isCategoryLoading?: boolean
|
isCategoryLoading?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
compact?: boolean
|
compact?: boolean
|
||||||
|
disableYearMonth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapSelectors({
|
export default function MapSelectors({
|
||||||
|
@ -35,6 +36,7 @@ export default function MapSelectors({
|
||||||
isCategoryLoading = false,
|
isCategoryLoading = false,
|
||||||
className = "",
|
className = "",
|
||||||
compact = false,
|
compact = false,
|
||||||
|
disableYearMonth = false,
|
||||||
}: MapSelectorsProps) {
|
}: MapSelectorsProps) {
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setSelectedYear(2024)
|
setSelectedYear(2024)
|
||||||
|
@ -50,6 +52,7 @@ export default function MapSelectors({
|
||||||
onYearChange={setSelectedYear}
|
onYearChange={setSelectedYear}
|
||||||
isLoading={isYearsLoading}
|
isLoading={isYearsLoading}
|
||||||
className={compact ? "w-full" : ""}
|
className={compact ? "w-full" : ""}
|
||||||
|
disabled={disableYearMonth}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MonthSelector
|
<MonthSelector
|
||||||
|
@ -57,6 +60,7 @@ export default function MapSelectors({
|
||||||
onMonthChange={setSelectedMonth}
|
onMonthChange={setSelectedMonth}
|
||||||
isLoading={isYearsLoading}
|
isLoading={isYearsLoading}
|
||||||
className={compact ? "w-full" : ""}
|
className={compact ? "w-full" : ""}
|
||||||
|
disabled={disableYearMonth}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
|
|
|
@ -26,6 +26,7 @@ interface MonthSelectorProps {
|
||||||
className?: string
|
className?: string
|
||||||
includeAllOption?: boolean
|
includeAllOption?: boolean
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MonthSelector({
|
export default function MonthSelector({
|
||||||
|
@ -34,6 +35,7 @@ export default function MonthSelector({
|
||||||
className = "w-[120px]",
|
className = "w-[120px]",
|
||||||
includeAllOption = true,
|
includeAllOption = true,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
|
disabled = false,
|
||||||
}: MonthSelectorProps) {
|
}: MonthSelectorProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [isClient, setIsClient] = useState(false)
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
@ -55,8 +57,9 @@ export default function MonthSelector({
|
||||||
<Select
|
<Select
|
||||||
value={selectedMonth.toString()}
|
value={selectedMonth.toString()}
|
||||||
onValueChange={(value) => onMonthChange(value === "all" ? "all" : Number(value))}
|
onValueChange={(value) => onMonthChange(value === "all" ? "all" : Number(value))}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={className}>
|
<SelectTrigger className={`${className} ${disabled ? 'opacity-60 cursor-not-allowed bg-muted' : ''}`}>
|
||||||
<SelectValue placeholder="Month" />
|
<SelectValue placeholder="Month" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent
|
<SelectContent
|
||||||
|
|
|
@ -1,39 +1,57 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@/app/_components/ui/button"
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
|
import {
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/app/_components/ui/popover"
|
Tooltip,
|
||||||
import { ChevronDown, Siren } from "lucide-react"
|
TooltipContent,
|
||||||
import { IconMessage } from "@tabler/icons-react"
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/app/_components/ui/tooltip";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/app/_components/ui/popover";
|
||||||
|
import { ChevronDown, Siren } from "lucide-react";
|
||||||
|
import { IconMessage } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ITooltipsControl } from "./tooltips"
|
import type { ITooltipsControl } from "./tooltips";
|
||||||
import MonthSelector from "../month-selector"
|
import MonthSelector from "../month-selector";
|
||||||
import YearSelector from "../year-selector"
|
import YearSelector from "../year-selector";
|
||||||
import CategorySelector from "../category-selector"
|
import CategorySelector from "../category-selector";
|
||||||
import SourceTypeSelector from "../source-type-selector"
|
import SourceTypeSelector from "../source-type-selector";
|
||||||
|
|
||||||
// Define the additional tools and features
|
// Define the additional tools and features
|
||||||
const additionalTooltips = [
|
const additionalTooltips = [
|
||||||
{ id: "reports" as ITooltipsControl, icon: <IconMessage size={20} />, label: "Police Report" },
|
{
|
||||||
{ id: "recents" as ITooltipsControl, icon: <Siren size={20} />, label: "Recent incidents" },
|
id: "reports" as ITooltipsControl,
|
||||||
]
|
icon: <IconMessage size={20} />,
|
||||||
|
label: "Police Report",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "recents" as ITooltipsControl,
|
||||||
|
icon: <Siren size={20} />,
|
||||||
|
label: "Recent incidents",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
interface AdditionalTooltipsProps {
|
interface AdditionalTooltipsProps {
|
||||||
activeControl?: string
|
activeControl?: string;
|
||||||
onControlChange?: (controlId: ITooltipsControl) => void
|
onControlChange?: (controlId: ITooltipsControl) => void;
|
||||||
selectedYear: number
|
selectedYear: number | "all";
|
||||||
setSelectedYear: (year: number) => void
|
setSelectedYear: (year: number | "all") => void;
|
||||||
selectedMonth: number | "all"
|
selectedMonth: number | "all";
|
||||||
setSelectedMonth: (month: number | "all") => void
|
setSelectedMonth: (month: number | "all") => void;
|
||||||
selectedCategory: string | "all"
|
selectedCategory: string | "all";
|
||||||
setSelectedCategory: (category: string | "all") => void
|
setSelectedCategory: (category: string | "all") => void;
|
||||||
selectedSourceType: string
|
selectedSourceType: string;
|
||||||
setSelectedSourceType: (sourceType: string) => void
|
setSelectedSourceType: (sourceType: string) => void;
|
||||||
availableYears?: (number | null)[]
|
availableYears?: (number | null)[];
|
||||||
availableSourceTypes?: string[]
|
availableSourceTypes?: string[];
|
||||||
categories?: string[]
|
categories?: string[];
|
||||||
panicButtonTriggered?: boolean
|
panicButtonTriggered?: boolean;
|
||||||
|
disableYearMonth?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdditionalTooltips({
|
export default function AdditionalTooltips({
|
||||||
|
@ -51,84 +69,113 @@ export default function AdditionalTooltips({
|
||||||
availableSourceTypes = [],
|
availableSourceTypes = [],
|
||||||
categories = [],
|
categories = [],
|
||||||
panicButtonTriggered = false,
|
panicButtonTriggered = false,
|
||||||
|
disableYearMonth = false,
|
||||||
}: AdditionalTooltipsProps) {
|
}: AdditionalTooltipsProps) {
|
||||||
const [showSelectors, setShowSelectors] = useState(false)
|
const [showSelectors, setShowSelectors] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isClient, setIsClient] = useState(false)
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
|
||||||
const container = isClient ? document.getElementById("root") : null
|
const container = isClient ? document.getElementById("root") : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (panicButtonTriggered && activeControl !== "alerts" && onControlChange) {
|
if (
|
||||||
onControlChange("alerts")
|
panicButtonTriggered && activeControl !== "alerts" &&
|
||||||
|
onControlChange
|
||||||
|
) {
|
||||||
|
onControlChange("alerts");
|
||||||
}
|
}
|
||||||
}, [panicButtonTriggered, activeControl, onControlChange])
|
}, [panicButtonTriggered, activeControl, onControlChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true)
|
setIsClient(true);
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const isControlDisabled = (controlId: ITooltipsControl) => {
|
const isControlDisabled = (controlId: ITooltipsControl) => {
|
||||||
// When source type is CBU, disable all controls except for layers
|
// When source type is CBU, disable all controls except for layers
|
||||||
return selectedSourceType === "cbu" && controlId !== "layers"
|
return selectedSourceType === "cbu" && controlId !== "layers";
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div ref={containerRef} className="z-10 bg-background rounded-md p-1 flex items-center space-x-1">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="z-10 bg-background rounded-md p-1 flex items-center space-x-1"
|
||||||
|
>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{additionalTooltips.map((control) => {
|
{additionalTooltips.map((control) => {
|
||||||
const isButtonDisabled = isControlDisabled(control.id)
|
const isButtonDisabled = isControlDisabled(control.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip key={control.id}>
|
<Tooltip key={control.id}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={activeControl === control.id ? "default" : "ghost"}
|
variant={activeControl === control.id
|
||||||
|
? "default"
|
||||||
|
: "ghost"}
|
||||||
size="medium"
|
size="medium"
|
||||||
className={`h-8 w-8 rounded-md ${isButtonDisabled
|
className={`h-8 w-8 rounded-md ${
|
||||||
|
isButtonDisabled
|
||||||
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
|
? "opacity-40 cursor-not-allowed bg-gray-700/30 text-gray-400 border-gray-600 hover:bg-gray-700/30 hover:text-gray-400"
|
||||||
: activeControl === control.id
|
: activeControl === control.id
|
||||||
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
? "bg-emerald-500 text-black hover:bg-emerald-500/90"
|
||||||
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
: "text-white hover:bg-emerald-500/90 hover:text-background"
|
||||||
} ${control.id === "alerts" && panicButtonTriggered ? "animate-pulse ring-2 ring-red-500" : ""}`}
|
} ${
|
||||||
onClick={() => onControlChange?.(control.id)}
|
control.id === "alerts" &&
|
||||||
|
panicButtonTriggered
|
||||||
|
? "animate-pulse ring-2 ring-red-500"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
onControlChange?.(control.id)}
|
||||||
disabled={isButtonDisabled}
|
disabled={isButtonDisabled}
|
||||||
aria-disabled={isButtonDisabled}
|
aria-disabled={isButtonDisabled}
|
||||||
>
|
>
|
||||||
{control.icon}
|
{control.icon}
|
||||||
<span className="sr-only">{control.label}</span>
|
<span className="sr-only">
|
||||||
|
{control.label}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
<p>{isButtonDisabled ? "Not available for CBU data" : control.label}</p>
|
<p>
|
||||||
|
{isButtonDisabled
|
||||||
|
? "Not available for CBU data"
|
||||||
|
: control.label}
|
||||||
|
</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Popover open={showSelectors} onOpenChange={setShowSelectors}>
|
<Popover
|
||||||
|
open={showSelectors}
|
||||||
|
onOpenChange={setShowSelectors}
|
||||||
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8 rounded-md text-white hover:bg-emerald-500/90 hover:text-background"
|
className="h-8 w-8 rounded-md text-white hover:bg-emerald-500/90 hover:text-background"
|
||||||
onClick={() => setShowSelectors(!showSelectors)}
|
onClick={() =>
|
||||||
|
setShowSelectors(!showSelectors)}
|
||||||
>
|
>
|
||||||
<ChevronDown size={20} />
|
<ChevronDown size={20} />
|
||||||
<span className="sr-only">Filters</span>
|
<span className="sr-only">Filters</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
container={containerRef.current || container || undefined}
|
container={containerRef.current || container ||
|
||||||
|
undefined}
|
||||||
className="w-auto p-3 bg-black/90 border-gray-700 text-white"
|
className="w-auto p-3 bg-black/90 border-gray-700 text-white"
|
||||||
align="end"
|
align="end"
|
||||||
style={{ zIndex: 2000 }}
|
style={{ zIndex: 2000 }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs w-16">Source:</span>
|
<span className="text-xs w-16">
|
||||||
|
Source:
|
||||||
|
</span>
|
||||||
<SourceTypeSelector
|
<SourceTypeSelector
|
||||||
availableSourceTypes={availableSourceTypes}
|
availableSourceTypes={availableSourceTypes}
|
||||||
selectedSourceType={selectedSourceType}
|
selectedSourceType={selectedSourceType}
|
||||||
|
@ -137,24 +184,32 @@ export default function AdditionalTooltips({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs w-16">Year:</span>
|
<span className="text-xs w-16">
|
||||||
|
Year:
|
||||||
|
</span>
|
||||||
<YearSelector
|
<YearSelector
|
||||||
availableYears={availableYears}
|
availableYears={availableYears}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear}
|
||||||
onYearChange={setSelectedYear}
|
onYearChange={setSelectedYear}
|
||||||
className="w-[180px]"
|
className="w-[180px]"
|
||||||
|
disabled={disableYearMonth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs w-16">Month:</span>
|
<span className="text-xs w-16">
|
||||||
|
Month:
|
||||||
|
</span>
|
||||||
<MonthSelector
|
<MonthSelector
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
onMonthChange={setSelectedMonth}
|
onMonthChange={setSelectedMonth}
|
||||||
className="w-[180px]"
|
className="w-[180px]"
|
||||||
|
disabled={disableYearMonth}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs w-16">Category:</span>
|
<span className="text-xs w-16">
|
||||||
|
Category:
|
||||||
|
</span>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
categories={categories}
|
categories={categories}
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
|
@ -183,7 +238,11 @@ export default function AdditionalTooltips({
|
||||||
onYearChange={setSelectedYear}
|
onYearChange={setSelectedYear}
|
||||||
className="w-[80px]"
|
className="w-[80px]"
|
||||||
/>
|
/>
|
||||||
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} className="w-[80px]" />
|
<MonthSelector
|
||||||
|
selectedMonth={selectedMonth}
|
||||||
|
onMonthChange={setSelectedMonth}
|
||||||
|
className="w-[80px]"
|
||||||
|
/>
|
||||||
<CategorySelector
|
<CategorySelector
|
||||||
categories={categories}
|
categories={categories}
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
|
@ -193,5 +252,5 @@ export default function AdditionalTooltips({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,9 +6,9 @@ import { AlertTriangle, Building, Car, Thermometer, History } from "lucide-react
|
||||||
import type { ITooltipsControl } from "./tooltips"
|
import type { ITooltipsControl } from "./tooltips"
|
||||||
import { IconChartBubble, IconClock } from "@tabler/icons-react"
|
import { IconChartBubble, IconClock } from "@tabler/icons-react"
|
||||||
|
|
||||||
// Define the primary crime data controls
|
// Update the tooltip for "incidents" to "All Incidents"
|
||||||
const crimeTooltips = [
|
const crimeTooltips = [
|
||||||
// { id: "incidents" as ITooltipsControl, icon: <AlertTriangle size={20} />, label: "All Incidents" },
|
{ id: "incidents" as ITooltipsControl, icon: <AlertTriangle size={20} />, label: "All Incidents" },
|
||||||
{ id: "heatmap" as ITooltipsControl, icon: <Thermometer size={20} />, label: "Density Heatmap" },
|
{ id: "heatmap" as ITooltipsControl, icon: <Thermometer size={20} />, label: "Density Heatmap" },
|
||||||
{ id: "units" as ITooltipsControl, icon: <Building size={20} />, label: "Police Units" },
|
{ id: "units" as ITooltipsControl, icon: <Building size={20} />, label: "Police Units" },
|
||||||
{ id: "clusters" as ITooltipsControl, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" },
|
{ id: "clusters" as ITooltipsControl, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" },
|
||||||
|
|
|
@ -11,7 +11,6 @@ import type { ReactNode } from "react"
|
||||||
export type ITooltipsControl =
|
export type ITooltipsControl =
|
||||||
// Crime data views
|
// Crime data views
|
||||||
| "incidents"
|
| "incidents"
|
||||||
| "historical"
|
|
||||||
| "heatmap"
|
| "heatmap"
|
||||||
| "units"
|
| "units"
|
||||||
| "patrol"
|
| "patrol"
|
||||||
|
@ -42,8 +41,8 @@ interface TooltipProps {
|
||||||
selectedSourceType: string
|
selectedSourceType: string
|
||||||
setSelectedSourceType: (sourceType: string) => void
|
setSelectedSourceType: (sourceType: string) => void
|
||||||
availableSourceTypes: string[] // This must be string[] to match with API response
|
availableSourceTypes: string[] // This must be string[] to match with API response
|
||||||
selectedYear: number
|
selectedYear: number | "all"
|
||||||
setSelectedYear: (year: number) => void
|
setSelectedYear: (year: number | "all") => void
|
||||||
selectedMonth: number | "all"
|
selectedMonth: number | "all"
|
||||||
setSelectedMonth: (month: number | "all") => void
|
setSelectedMonth: (month: number | "all") => void
|
||||||
selectedCategory: string | "all"
|
selectedCategory: string | "all"
|
||||||
|
@ -51,6 +50,7 @@ interface TooltipProps {
|
||||||
availableYears?: (number | null)[]
|
availableYears?: (number | null)[]
|
||||||
categories?: string[]
|
categories?: string[]
|
||||||
crimes?: any[] // Add this prop to receive crime data
|
crimes?: any[] // Add this prop to receive crime data
|
||||||
|
disableYearMonth?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tooltips({
|
export default function Tooltips({
|
||||||
|
@ -68,6 +68,7 @@ export default function Tooltips({
|
||||||
availableYears = [],
|
availableYears = [],
|
||||||
categories = [],
|
categories = [],
|
||||||
crimes = [],
|
crimes = [],
|
||||||
|
disableYearMonth = false,
|
||||||
}: TooltipProps) {
|
}: TooltipProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [isClient, setIsClient] = useState(false)
|
const [isClient, setIsClient] = useState(false)
|
||||||
|
@ -97,6 +98,7 @@ export default function Tooltips({
|
||||||
setSelectedCategory={setSelectedCategory}
|
setSelectedCategory={setSelectedCategory}
|
||||||
availableYears={availableYears}
|
availableYears={availableYears}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
|
disableYearMonth={disableYearMonth}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Search Control Component */}
|
{/* Search Control Component */}
|
||||||
|
|
|
@ -1,24 +1,24 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
|
import {
|
||||||
import { createRoot } from "react-dom/client"
|
Select,
|
||||||
import { useRef, useEffect, useState } from "react"
|
SelectContent,
|
||||||
import { Skeleton } from "../../ui/skeleton"
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
interface YearSelectorProps {
|
SelectValue,
|
||||||
availableYears?: (number | null)[]
|
} from "@/app/_components/ui/select";
|
||||||
selectedYear: number
|
import { createRoot } from "react-dom/client";
|
||||||
onYearChange: (year: number) => void
|
import { useEffect, useRef, useState } from "react";
|
||||||
isLoading?: boolean
|
import { Skeleton } from "../../ui/skeleton";
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface YearSelectorProps {
|
interface YearSelectorProps {
|
||||||
availableYears?: (number | null)[];
|
availableYears?: (number | null)[];
|
||||||
selectedYear: number;
|
selectedYear: number | "all";
|
||||||
onYearChange: (year: number) => void;
|
onYearChange: (year: number | "all") => void;
|
||||||
|
includeAllOption?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// React component for the year selector UI
|
// React component for the year selector UI
|
||||||
|
@ -26,8 +26,10 @@ function YearSelectorUI({
|
||||||
availableYears = [],
|
availableYears = [],
|
||||||
selectedYear,
|
selectedYear,
|
||||||
onYearChange,
|
onYearChange,
|
||||||
|
includeAllOption = true,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
className = "w-[120px]"
|
className = "w-[120px]",
|
||||||
|
disabled = false,
|
||||||
}: YearSelectorProps) {
|
}: YearSelectorProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
@ -41,32 +43,50 @@ function YearSelectorUI({
|
||||||
const container = isClient ? document.getElementById("root") : null;
|
const container = isClient ? document.getElementById("root") : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="mapboxgl-year-selector">
|
<div ref={containerRef} className="mapboxgl-month-selector">
|
||||||
{isLoading ? (
|
{isLoading
|
||||||
<div className=" h-8">
|
? (
|
||||||
|
<div className="flex items-center justify-center h-8">
|
||||||
<Skeleton className="h-full w-full rounded-md" />
|
<Skeleton className="h-full w-full rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)
|
||||||
|
: (
|
||||||
<Select
|
<Select
|
||||||
value={selectedYear.toString()}
|
value={selectedYear.toString()}
|
||||||
onValueChange={(value) => onYearChange(Number(value))}
|
onValueChange={(value) =>
|
||||||
disabled={isLoading}
|
onYearChange(
|
||||||
|
value === "all" ? "all" : Number(value),
|
||||||
|
)}
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={className}>
|
<SelectTrigger
|
||||||
<SelectValue placeholder="Year" />
|
className={`${className} ${
|
||||||
|
disabled
|
||||||
|
? "opacity-60 cursor-not-allowed bg-muted"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Month" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
{/* Ensure that the dropdown content renders correctly only on the client side */}
|
|
||||||
<SelectContent
|
<SelectContent
|
||||||
container={containerRef.current || container || undefined}
|
container={containerRef.current || container ||
|
||||||
|
undefined}
|
||||||
style={{ zIndex: 2000 }}
|
style={{ zIndex: 2000 }}
|
||||||
className={`${className}`}
|
className={`${className}`}
|
||||||
>
|
>
|
||||||
{availableYears
|
{includeAllOption && (
|
||||||
?.filter((year) => year !== null)
|
<SelectItem value="all">All Years</SelectItem>
|
||||||
.map((year) => (
|
)}
|
||||||
<SelectItem key={year} value={year!.toString()}>
|
|
||||||
|
{availableYears.map((year) => (
|
||||||
|
year !== null && (
|
||||||
|
<SelectItem
|
||||||
|
key={year}
|
||||||
|
value={year.toString()}
|
||||||
|
>
|
||||||
{year}
|
{year}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
)
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
@ -88,14 +108,14 @@ export class YearSelectorControl {
|
||||||
|
|
||||||
onAdd(map: any) {
|
onAdd(map: any) {
|
||||||
this._map = map;
|
this._map = map;
|
||||||
this._container = document.createElement('div');
|
this._container = document.createElement("div");
|
||||||
this._container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group';
|
this._container.className = "mapboxgl-ctrl mapboxgl-ctrl-group";
|
||||||
this._container.style.padding = '5px';
|
this._container.style.padding = "5px";
|
||||||
|
|
||||||
// Set position to relative to keep dropdown content in context
|
// Set position to relative to keep dropdown content in context
|
||||||
this._container.style.position = 'relative';
|
this._container.style.position = "relative";
|
||||||
// Higher z-index to ensure dropdown appears above map elements
|
// Higher z-index to ensure dropdown appears above map elements
|
||||||
this._container.style.zIndex = '50';
|
this._container.style.zIndex = "50";
|
||||||
|
|
||||||
// Create React root for rendering our component
|
// Create React root for rendering our component
|
||||||
this._root = createRoot(this._container);
|
this._root = createRoot(this._container);
|
||||||
|
|
|
@ -1,96 +1,143 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
|
import {
|
||||||
import { Skeleton } from "@/app/_components/ui/skeleton"
|
Card,
|
||||||
import MapView from "./map"
|
CardContent,
|
||||||
import { Button } from "@/app/_components/ui/button"
|
CardHeader,
|
||||||
import { AlertCircle } from "lucide-react"
|
CardTitle,
|
||||||
import { getMonthName } from "@/app/_utils/common"
|
} from "@/app/_components/ui/card";
|
||||||
import { useRef, useState, useCallback, useMemo, useEffect } from "react"
|
import { Skeleton } from "@/app/_components/ui/skeleton";
|
||||||
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
|
import MapView from "./map";
|
||||||
import { Overlay } from "./overlay"
|
import { Button } from "@/app/_components/ui/button";
|
||||||
import MapLegend from "./legends/map-legend"
|
import { AlertCircle } from "lucide-react";
|
||||||
import UnitsLegend from "./legends/units-legend"
|
import { getMonthName } from "@/app/_utils/common";
|
||||||
import TimelineLegend from "./legends/timeline-legend"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes, useGetCrimeTypes, useGetRecentIncidents } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
|
import { useFullscreen } from "@/app/_hooks/use-fullscreen";
|
||||||
import MapSelectors from "./controls/map-selector"
|
import { Overlay } from "./overlay";
|
||||||
|
import MapLegend from "./legends/map-legend";
|
||||||
|
import UnitsLegend from "./legends/units-legend";
|
||||||
|
import TimelineLegend from "./legends/timeline-legend";
|
||||||
|
import {
|
||||||
|
useGetAvailableYears,
|
||||||
|
useGetCrimeCategories,
|
||||||
|
useGetCrimes,
|
||||||
|
useGetCrimeTypes,
|
||||||
|
useGetRecentIncidents,
|
||||||
|
} from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries";
|
||||||
|
import MapSelectors from "./controls/map-selector";
|
||||||
|
|
||||||
import { cn } from "@/app/_lib/utils"
|
import { cn } from "@/app/_lib/utils";
|
||||||
import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client"
|
import {
|
||||||
import { CrimeTimelapse } from "./controls/bottom/crime-timelapse"
|
$Enums,
|
||||||
import { ITooltipsControl } from "./controls/top/tooltips"
|
crime_categories,
|
||||||
import CrimeSidebar from "./controls/left/sidebar/map-sidebar"
|
crime_incidents,
|
||||||
import Tooltips from "./controls/top/tooltips"
|
crimes,
|
||||||
import Layers from "./layers/layers"
|
demographics,
|
||||||
|
districts,
|
||||||
|
geographics,
|
||||||
|
locations,
|
||||||
|
} from "@prisma/client";
|
||||||
|
import { CrimeTimelapse } from "./controls/bottom/crime-timelapse";
|
||||||
|
import { ITooltipsControl } from "./controls/top/tooltips";
|
||||||
|
import CrimeSidebar from "./controls/left/sidebar/map-sidebar";
|
||||||
|
import Tooltips from "./controls/top/tooltips";
|
||||||
|
import Layers from "./layers/layers";
|
||||||
|
|
||||||
import { useGetUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries"
|
import { useGetUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries";
|
||||||
import { IDistrictFeature } from "@/app/_utils/types/map"
|
import { IDistrictFeature } from "@/app/_utils/types/map";
|
||||||
import EWSAlertLayer from "./layers/ews-alert-layer"
|
import EWSAlertLayer from "./layers/ews-alert-layer";
|
||||||
import { IIncidentLog } from "@/app/_utils/types/ews"
|
import { IIncidentLog } from "@/app/_utils/types/ews";
|
||||||
import { addMockIncident, getAllIncidents, resolveIncident } from "@/app/_utils/mock/ews-data"
|
import {
|
||||||
import { useMap } from "react-map-gl/mapbox"
|
addMockIncident,
|
||||||
import PanicButtonDemo from "./controls/panic-button-demo"
|
getAllIncidents,
|
||||||
|
resolveIncident,
|
||||||
|
} from "@/app/_utils/mock/ews-data";
|
||||||
|
import { useMap } from "react-map-gl/mapbox";
|
||||||
|
import PanicButtonDemo from "./controls/panic-button-demo";
|
||||||
|
|
||||||
export default function CrimeMap() {
|
export default function CrimeMap() {
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||||
const [selectedDistrict, setSelectedDistrict] = useState<IDistrictFeature | null>(null)
|
const [activeControl, setActiveControl] = useState<ITooltipsControl>(
|
||||||
const [showLegend, setShowLegend] = useState<boolean>(true)
|
"clusters",
|
||||||
const [activeControl, setActiveControl] = useState<ITooltipsControl>("clusters")
|
);
|
||||||
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu")
|
const [selectedDistrict, setSelectedDistrict] = useState<
|
||||||
const [selectedYear, setSelectedYear] = useState<number>(2024)
|
IDistrictFeature | null
|
||||||
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
|
>(null);
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
|
const [selectedSourceType, setSelectedSourceType] = useState<string>("cbu");
|
||||||
const [yearProgress, setYearProgress] = useState(0)
|
const [selectedYear, setSelectedYear] = useState<number | "all">(2024);
|
||||||
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
|
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all");
|
||||||
const [isSearchActive, setIsSearchActive] = useState(false)
|
const [selectedCategory, setSelectedCategory] = useState<string | "all">(
|
||||||
const [showUnitsLayer, setShowUnitsLayer] = useState(false)
|
"all",
|
||||||
const [showClusters, setShowClusters] = useState(false)
|
);
|
||||||
const [showHeatmap, setShowHeatmap] = useState(false)
|
const [ewsIncidents, setEwsIncidents] = useState<IIncidentLog[]>([]);
|
||||||
const [showUnclustered, setShowUnclustered] = useState(true)
|
|
||||||
const [useAllYears, setUseAllYears] = useState<boolean>(false)
|
|
||||||
const [useAllMonths, setUseAllMonths] = useState<boolean>(false)
|
|
||||||
const [showEWS, setShowEWS] = useState<boolean>(true)
|
|
||||||
const [ewsIncidents, setEwsIncidents] = useState<IIncidentLog[]>([])
|
|
||||||
const [showPanicDemo, setShowPanicDemo] = useState(true)
|
|
||||||
const [displayPanicDemo, setDisplayPanicDemo] = useState(showEWS && showPanicDemo)
|
|
||||||
|
|
||||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
const [useAllYears, setUseAllYears] = useState<boolean>(false);
|
||||||
|
const [useAllMonths, setUseAllMonths] = useState<boolean>(false);
|
||||||
|
|
||||||
const { current: mapInstance } = useMap()
|
const [showAllIncidents, setShowAllIncidents] = useState(false);
|
||||||
|
const [showLegend, setShowLegend] = useState<boolean>(true);
|
||||||
|
const [showUnitsLayer, setShowUnitsLayer] = useState(false);
|
||||||
|
const [showClusters, setShowClusters] = useState(false);
|
||||||
|
const [showHeatmap, setShowHeatmap] = useState(false);
|
||||||
|
const [showTimelineLayer, setShowTimelineLayer] = useState(false);
|
||||||
|
const [showEWS, setShowEWS] = useState<boolean>(true);
|
||||||
|
const [showPanicDemo, setShowPanicDemo] = useState(true);
|
||||||
|
const [displayPanicDemo, setDisplayPanicDemo] = useState(
|
||||||
|
showEWS && showPanicDemo,
|
||||||
|
);
|
||||||
|
|
||||||
const mapboxMap = mapInstance?.getMap() || null
|
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false);
|
||||||
|
const [yearProgress, setYearProgress] = useState(0);
|
||||||
|
const [isSearchActive, setIsSearchActive] = useState(false);
|
||||||
|
|
||||||
const { isFullscreen } = useFullscreen(mapContainerRef)
|
const mapContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: availableSourceTypes, isLoading: isTypeLoading } = useGetCrimeTypes()
|
const { current: mapInstance } = useMap();
|
||||||
|
|
||||||
|
const mapboxMap = mapInstance?.getMap() || null;
|
||||||
|
|
||||||
|
const { isFullscreen } = useFullscreen(mapContainerRef);
|
||||||
|
|
||||||
|
const { data: availableSourceTypes, isLoading: isTypeLoading } =
|
||||||
|
useGetCrimeTypes();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: availableYears,
|
data: availableYears,
|
||||||
isLoading: isYearsLoading,
|
isLoading: isYearsLoading,
|
||||||
error: yearsError
|
error: yearsError,
|
||||||
} = useGetAvailableYears()
|
} = useGetAvailableYears();
|
||||||
|
|
||||||
const { data: categoriesData, isLoading: isCategoryLoading } = useGetCrimeCategories()
|
const { data: categoriesData, isLoading: isCategoryLoading } =
|
||||||
|
useGetCrimeCategories();
|
||||||
|
|
||||||
const categories = useMemo(() =>
|
const categories = useMemo(
|
||||||
categoriesData ? categoriesData.map(category => category.name) : []
|
() =>
|
||||||
, [categoriesData])
|
categoriesData
|
||||||
|
? categoriesData.map((category) => category.name)
|
||||||
|
: [],
|
||||||
|
[categoriesData],
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: crimes,
|
data: crimes,
|
||||||
isLoading: isCrimesLoading,
|
isLoading: isCrimesLoading,
|
||||||
error: crimesError
|
error: crimesError,
|
||||||
} = useGetCrimes()
|
} = useGetCrimes();
|
||||||
|
|
||||||
const { data: fetchedUnits, isLoading } = useGetUnitsQuery()
|
const { data: fetchedUnits, isLoading } = useGetUnitsQuery();
|
||||||
|
|
||||||
const { data: recentIncidents } = useGetRecentIncidents()
|
const { data: recentIncidents } = useGetRecentIncidents();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeControl === "heatmap" || activeControl === "timeline") {
|
if (
|
||||||
|
activeControl === "heatmap" || activeControl === "timeline" ||
|
||||||
|
activeControl === "incidents"
|
||||||
|
) {
|
||||||
|
setSelectedYear("all");
|
||||||
setUseAllYears(true);
|
setUseAllYears(true);
|
||||||
setUseAllMonths(true);
|
setUseAllMonths(true);
|
||||||
} else {
|
} else {
|
||||||
|
setSelectedYear(2024);
|
||||||
setUseAllYears(false);
|
setUseAllYears(false);
|
||||||
setUseAllMonths(false);
|
setUseAllMonths(false);
|
||||||
}
|
}
|
||||||
|
@ -98,7 +145,9 @@ export default function CrimeMap() {
|
||||||
|
|
||||||
const crimesBySourceType = useMemo(() => {
|
const crimesBySourceType = useMemo(() => {
|
||||||
if (!crimes) return [];
|
if (!crimes) return [];
|
||||||
return crimes.filter(crime => crime.source_type === selectedSourceType);
|
return crimes.filter((crime) =>
|
||||||
|
crime.source_type === selectedSourceType
|
||||||
|
);
|
||||||
}, [crimes, selectedSourceType]);
|
}, [crimes, selectedSourceType]);
|
||||||
|
|
||||||
const filteredByYearAndMonth = useMemo(() => {
|
const filteredByYearAndMonth = useMemo(() => {
|
||||||
|
@ -109,7 +158,9 @@ export default function CrimeMap() {
|
||||||
return crimesBySourceType;
|
return crimesBySourceType;
|
||||||
} else {
|
} else {
|
||||||
return crimesBySourceType.filter((crime) => {
|
return crimesBySourceType.filter((crime) => {
|
||||||
return selectedMonth === "all" ? true : crime.month === selectedMonth;
|
return selectedMonth === "all"
|
||||||
|
? true
|
||||||
|
: crime.month === selectedMonth;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -123,59 +174,70 @@ export default function CrimeMap() {
|
||||||
return yearMatch && crime.month === selectedMonth;
|
return yearMatch && crime.month === selectedMonth;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [crimesBySourceType, selectedYear, selectedMonth, useAllYears, useAllMonths]);
|
}, [
|
||||||
|
crimesBySourceType,
|
||||||
|
selectedYear,
|
||||||
|
selectedMonth,
|
||||||
|
useAllYears,
|
||||||
|
useAllMonths,
|
||||||
|
]);
|
||||||
|
|
||||||
const filteredCrimes = useMemo(() => {
|
const filteredCrimes = useMemo(() => {
|
||||||
if (!filteredByYearAndMonth) return []
|
if (!filteredByYearAndMonth) return [];
|
||||||
if (selectedCategory === "all") return filteredByYearAndMonth
|
if (selectedCategory === "all") return filteredByYearAndMonth;
|
||||||
|
|
||||||
return filteredByYearAndMonth.map((crime) => {
|
return filteredByYearAndMonth.map((crime) => {
|
||||||
const filteredIncidents = crime.crime_incidents.filter(
|
const filteredIncidents = crime.crime_incidents.filter(
|
||||||
incident => incident.crime_categories.name === selectedCategory
|
(incident) =>
|
||||||
)
|
incident.crime_categories.name === selectedCategory,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...crime,
|
...crime,
|
||||||
crime_incidents: filteredIncidents,
|
crime_incidents: filteredIncidents,
|
||||||
number_of_crime: filteredIncidents.length
|
number_of_crime: filteredIncidents.length,
|
||||||
}
|
};
|
||||||
})
|
});
|
||||||
}, [filteredByYearAndMonth, selectedCategory])
|
}, [filteredByYearAndMonth, selectedCategory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSourceType === "cbu") {
|
if (selectedSourceType === "cbu") {
|
||||||
if (activeControl !== "clusters" && activeControl !== "reports" &&
|
if (
|
||||||
|
activeControl !== "clusters" && activeControl !== "reports" &&
|
||||||
activeControl !== "layers" && activeControl !== "search" &&
|
activeControl !== "layers" && activeControl !== "search" &&
|
||||||
activeControl !== "alerts") {
|
activeControl !== "alerts"
|
||||||
|
) {
|
||||||
setActiveControl("clusters");
|
setActiveControl("clusters");
|
||||||
setShowClusters(true);
|
setShowClusters(true);
|
||||||
setShowUnclustered(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [selectedSourceType, activeControl]);
|
}, [selectedSourceType, activeControl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEwsIncidents(getAllIncidents())
|
setEwsIncidents(getAllIncidents());
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleTriggerAlert = useCallback((priority: "high" | "medium" | "low") => {
|
const handleTriggerAlert = useCallback(
|
||||||
const newIncident = addMockIncident({ priority })
|
(priority: "high" | "medium" | "low") => {
|
||||||
setEwsIncidents(getAllIncidents())
|
const newIncident = addMockIncident({ priority });
|
||||||
}, [])
|
setEwsIncidents(getAllIncidents());
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleResolveIncident = useCallback((id: string) => {
|
const handleResolveIncident = useCallback((id: string) => {
|
||||||
resolveIncident(id)
|
resolveIncident(id);
|
||||||
setEwsIncidents(getAllIncidents())
|
setEwsIncidents(getAllIncidents());
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleResolveAllAlerts = useCallback(() => {
|
const handleResolveAllAlerts = useCallback(() => {
|
||||||
ewsIncidents.forEach((incident) => {
|
ewsIncidents.forEach((incident) => {
|
||||||
if (incident.status === "active") {
|
if (incident.status === "active") {
|
||||||
resolveIncident(incident.id)
|
resolveIncident(incident.id);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
setEwsIncidents(getAllIncidents())
|
setEwsIncidents(getAllIncidents());
|
||||||
}, [ewsIncidents])
|
}, [ewsIncidents]);
|
||||||
|
|
||||||
const handleSourceTypeChange = useCallback((sourceType: string) => {
|
const handleSourceTypeChange = useCallback((sourceType: string) => {
|
||||||
setSelectedSourceType(sourceType);
|
setSelectedSourceType(sourceType);
|
||||||
|
@ -183,37 +245,40 @@ export default function CrimeMap() {
|
||||||
if (sourceType === "cbu") {
|
if (sourceType === "cbu") {
|
||||||
setActiveControl("clusters");
|
setActiveControl("clusters");
|
||||||
setShowClusters(true);
|
setShowClusters(true);
|
||||||
setShowUnclustered(false);
|
|
||||||
} else {
|
} else {
|
||||||
setActiveControl("clusters");
|
setActiveControl("clusters");
|
||||||
setShowUnclustered(true);
|
|
||||||
setShowClusters(false);
|
setShowClusters(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
|
const handleTimelineChange = useCallback(
|
||||||
setSelectedYear(year)
|
(year: number, month: number, progress: number) => {
|
||||||
setSelectedMonth(month)
|
setSelectedYear(year);
|
||||||
setYearProgress(progress)
|
setSelectedMonth(month);
|
||||||
}, [])
|
setYearProgress(progress);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleTimelinePlayingChange = useCallback((playing: boolean) => {
|
const handleTimelinePlayingChange = useCallback((playing: boolean) => {
|
||||||
setisTimelapsePlaying(playing)
|
setisTimelapsePlaying(playing);
|
||||||
|
|
||||||
if (playing) {
|
if (playing) {
|
||||||
setSelectedDistrict(null)
|
setSelectedDistrict(null);
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const resetFilters = useCallback(() => {
|
const resetFilters = useCallback(() => {
|
||||||
setSelectedYear(2024)
|
setSelectedYear(2024);
|
||||||
setSelectedMonth("all")
|
setSelectedMonth("all");
|
||||||
setSelectedCategory("all")
|
setSelectedCategory("all");
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const getMapTitle = () => {
|
const getMapTitle = () => {
|
||||||
if (useAllYears) {
|
if (useAllYears) {
|
||||||
return `All Years Data ${selectedCategory !== "all" ? `- ${selectedCategory}` : ''}`;
|
return `All Years Data ${
|
||||||
|
selectedCategory !== "all" ? `- ${selectedCategory}` : ""
|
||||||
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let title = `${selectedYear}`;
|
let title = `${selectedYear}`;
|
||||||
|
@ -224,39 +289,52 @@ export default function CrimeMap() {
|
||||||
title += ` - ${selectedCategory}`;
|
title += ` - ${selectedCategory}`;
|
||||||
}
|
}
|
||||||
return title;
|
return title;
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleControlChange = (controlId: ITooltipsControl) => {
|
const handleControlChange = (controlId: ITooltipsControl) => {
|
||||||
if (selectedSourceType === "cbu" &&
|
if (
|
||||||
!["clusters", "reports", "layers", "search", "alerts"].includes(controlId as string)) {
|
selectedSourceType === "cbu" &&
|
||||||
|
!["clusters", "reports", "layers", "search", "alerts"].includes(
|
||||||
|
controlId as string,
|
||||||
|
)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveControl(controlId);
|
setActiveControl(controlId);
|
||||||
|
|
||||||
if (controlId === "clusters") {
|
if (controlId === "clusters") {
|
||||||
setShowClusters(true)
|
setShowClusters(true);
|
||||||
} else {
|
} else {
|
||||||
setShowClusters(false)
|
setShowClusters(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controlId === "incidents") {
|
if (controlId === "incidents") {
|
||||||
setShowUnclustered(true)
|
setShowAllIncidents(true);
|
||||||
} else {
|
} else {
|
||||||
setShowUnclustered(false)
|
setShowAllIncidents(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controlId === "search") {
|
if (controlId === "search") {
|
||||||
setIsSearchActive(prev => !prev);
|
setIsSearchActive((prev) => !prev);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controlId === "units") {
|
if (controlId === "units") {
|
||||||
setShowUnitsLayer(true);
|
setShowUnitsLayer(true);
|
||||||
} else if (showUnitsLayer) {
|
} else {
|
||||||
setShowUnitsLayer(false);
|
setShowUnitsLayer(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controlId === "heatmap" || controlId === "timeline") {
|
if (controlId === "timeline") {
|
||||||
|
setShowTimelineLayer(true);
|
||||||
|
} else {
|
||||||
|
setShowTimelineLayer(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
controlId === "heatmap" || controlId === "timeline" ||
|
||||||
|
controlId === "incidents"
|
||||||
|
) {
|
||||||
setUseAllYears(true);
|
setUseAllYears(true);
|
||||||
setUseAllMonths(true);
|
setUseAllMonths(true);
|
||||||
} else {
|
} else {
|
||||||
|
@ -265,9 +343,9 @@ export default function CrimeMap() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowEWS(true);
|
setShowEWS(true);
|
||||||
}
|
};
|
||||||
|
|
||||||
const showTimelineLayer = activeControl === "timeline";
|
// const showTimelineLayer = activeControl === "timeline";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full p-0 border-none shadow-none h-96">
|
<Card className="w-full p-0 border-none shadow-none h-96">
|
||||||
|
@ -284,27 +362,48 @@ export default function CrimeMap() {
|
||||||
categories={categories}
|
categories={categories}
|
||||||
isYearsLoading={isYearsLoading}
|
isYearsLoading={isYearsLoading}
|
||||||
isCategoryLoading={isCategoryLoading}
|
isCategoryLoading={isCategoryLoading}
|
||||||
|
disableYearMonth={activeControl === "incidents" ||
|
||||||
|
activeControl === "heatmap" ||
|
||||||
|
activeControl === "timeline"}
|
||||||
/>
|
/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
{isCrimesLoading ? (
|
{isCrimesLoading
|
||||||
|
? (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
<Skeleton className="h-full w-full rounded-md" />
|
<Skeleton className="h-full w-full rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
) : crimesError ? (
|
)
|
||||||
|
: crimesError
|
||||||
|
? (
|
||||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||||
<AlertCircle className="h-10 w-10 text-destructive" />
|
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||||
<p className="text-center">Failed to load crime data. Please try again later.</p>
|
<p className="text-center">
|
||||||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
Failed to load crime data. Please try again
|
||||||
|
later.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => window.location.reload()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)
|
||||||
<div className="mapbox-container overlay-bg relative h-[600px]" ref={mapContainerRef}>
|
: (
|
||||||
<div className={cn(
|
<div
|
||||||
|
className="mapbox-container overlay-bg relative h-[600px]"
|
||||||
|
ref={mapContainerRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
"transition-all duration-300 ease-in-out",
|
"transition-all duration-300 ease-in-out",
|
||||||
!sidebarCollapsed && isFullscreen && "ml-[400px]"
|
!sidebarCollapsed && isFullscreen &&
|
||||||
)}>
|
"ml-[400px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="">
|
<div className="">
|
||||||
<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"
|
||||||
|
>
|
||||||
<Layers
|
<Layers
|
||||||
crimes={filteredCrimes || []}
|
crimes={filteredCrimes || []}
|
||||||
units={fetchedUnits || []}
|
units={fetchedUnits || []}
|
||||||
|
@ -314,11 +413,11 @@ export default function CrimeMap() {
|
||||||
activeControl={activeControl}
|
activeControl={activeControl}
|
||||||
useAllData={useAllYears}
|
useAllData={useAllYears}
|
||||||
showEWS={showEWS}
|
showEWS={showEWS}
|
||||||
recentIncidents={recentIncidents || []}
|
recentIncidents={recentIncidents ||
|
||||||
|
[]}
|
||||||
sourceType={selectedSourceType}
|
sourceType={selectedSourceType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
{isFullscreen && (
|
{isFullscreen && (
|
||||||
<>
|
<>
|
||||||
<div className="absolute flex w-full p-2">
|
<div className="absolute flex w-full p-2">
|
||||||
|
@ -327,34 +426,51 @@ export default function CrimeMap() {
|
||||||
onControlChange={handleControlChange}
|
onControlChange={handleControlChange}
|
||||||
selectedSourceType={selectedSourceType}
|
selectedSourceType={selectedSourceType}
|
||||||
setSelectedSourceType={handleSourceTypeChange}
|
setSelectedSourceType={handleSourceTypeChange}
|
||||||
availableSourceTypes={availableSourceTypes || []}
|
availableSourceTypes={availableSourceTypes ||
|
||||||
|
[]}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear}
|
||||||
setSelectedYear={setSelectedYear}
|
setSelectedYear={setSelectedYear}
|
||||||
selectedMonth={selectedMonth}
|
selectedMonth={selectedMonth}
|
||||||
setSelectedMonth={setSelectedMonth}
|
setSelectedMonth={setSelectedMonth}
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
setSelectedCategory={setSelectedCategory}
|
setSelectedCategory={setSelectedCategory}
|
||||||
availableYears={availableYears || []}
|
availableYears={availableYears ||
|
||||||
|
[]}
|
||||||
categories={categories}
|
categories={categories}
|
||||||
crimes={filteredCrimes}
|
crimes={filteredCrimes}
|
||||||
|
disableYearMonth={activeControl ===
|
||||||
|
"incidents" ||
|
||||||
|
activeControl ===
|
||||||
|
"heatmap" ||
|
||||||
|
activeControl ===
|
||||||
|
"timeline"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mapboxMap && (
|
{mapboxMap && (
|
||||||
<EWSAlertLayer map={mapboxMap} incidents={ewsIncidents} onIncidentResolved={handleResolveIncident} />
|
<EWSAlertLayer
|
||||||
|
map={mapboxMap}
|
||||||
|
incidents={ewsIncidents}
|
||||||
|
onIncidentResolved={handleResolveIncident}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{displayPanicDemo && (
|
{displayPanicDemo && (
|
||||||
<div className="absolute top-0 right-20 z-50 p-2">
|
<div className="absolute top-0 right-20 z-50 p-2">
|
||||||
<PanicButtonDemo
|
<PanicButtonDemo
|
||||||
onTriggerAlert={handleTriggerAlert}
|
onTriggerAlert={handleTriggerAlert}
|
||||||
onResolveAllAlerts={handleResolveAllAlerts}
|
onResolveAllAlerts={handleResolveAllAlerts}
|
||||||
activeIncidents={ewsIncidents.filter((inc) => inc.status === "active")}
|
activeIncidents={ewsIncidents
|
||||||
|
.filter((inc) =>
|
||||||
|
inc.status ===
|
||||||
|
"active"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CrimeSidebar
|
<CrimeSidebar
|
||||||
crimes={filteredCrimes || []}
|
crimes={filteredCrimes ||[]}
|
||||||
defaultCollapsed={sidebarCollapsed}
|
defaultCollapsed={sidebarCollapsed}
|
||||||
selectedCategory={selectedCategory}
|
selectedCategory={selectedCategory}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear}
|
||||||
|
@ -366,12 +482,12 @@ export default function CrimeMap() {
|
||||||
{showClusters && (
|
{showClusters && (
|
||||||
<MapLegend position="bottom-right" />
|
<MapLegend position="bottom-right" />
|
||||||
)}
|
)}
|
||||||
{showUnclustered && !showClusters && (
|
|
||||||
|
{!showClusters && (
|
||||||
<MapLegend position="bottom-right" />
|
<MapLegend position="bottom-right" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{showUnitsLayer && (
|
{showUnitsLayer && (
|
||||||
<div className="absolute bottom-20 right-0 z-10 p-2">
|
<div className="absolute bottom-20 right-0 z-10 p-2">
|
||||||
<UnitsLegend
|
<UnitsLegend
|
||||||
|
@ -389,7 +505,6 @@ export default function CrimeMap() {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="absolute flex w-full bottom-0">
|
<div className="absolute flex w-full bottom-0">
|
||||||
<CrimeTimelapse
|
<CrimeTimelapse
|
||||||
startYear={2020}
|
startYear={2020}
|
||||||
|
@ -399,7 +514,6 @@ export default function CrimeMap() {
|
||||||
onPlayingChange={handleTimelinePlayingChange}
|
onPlayingChange={handleTimelinePlayingChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</MapView>
|
</MapView>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -407,5 +521,5 @@ export default function CrimeMap() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,429 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { ICrimes } from "@/app/_utils/types/crimes";
|
||||||
|
import {
|
||||||
|
BASE_BEARING,
|
||||||
|
BASE_DURATION,
|
||||||
|
BASE_PITCH,
|
||||||
|
BASE_ZOOM,
|
||||||
|
PITCH_3D,
|
||||||
|
ZOOM_3D,
|
||||||
|
} from "@/app/_utils/const/map";
|
||||||
|
import IncidentPopup from "../pop-up/incident-popup";
|
||||||
|
import type mapboxgl from "mapbox-gl";
|
||||||
|
import type { MapGeoJSONFeature, MapMouseEvent } from "react-map-gl/mapbox";
|
||||||
|
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility";
|
||||||
|
|
||||||
|
interface IAllIncidentsLayerProps {
|
||||||
|
visible?: boolean;
|
||||||
|
map: mapboxgl.Map | null;
|
||||||
|
crimes: ICrimes[];
|
||||||
|
filterCategory: string | "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IIncidentFeatureProperties {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: string;
|
||||||
|
district: string;
|
||||||
|
district_id: string;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
address: string | null;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IIncidentDetails {
|
||||||
|
id: string;
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: Date;
|
||||||
|
district: string;
|
||||||
|
district_id: string;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
address: string | null;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AllIncidentsLayer(
|
||||||
|
{ visible = false, map, crimes = [], filterCategory = "all" }:
|
||||||
|
IAllIncidentsLayerProps,
|
||||||
|
) {
|
||||||
|
const isInteractingWithMarker = useRef(false);
|
||||||
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
const [selectedIncident, setSelectedIncident] = useState<
|
||||||
|
IIncidentDetails | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
// Define layer IDs for consistent management
|
||||||
|
const LAYER_IDS = [
|
||||||
|
"all-incidents-pulse",
|
||||||
|
"all-incidents-circles",
|
||||||
|
"all-incidents",
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleIncidentClick = useCallback(
|
||||||
|
(e: MapMouseEvent & { features?: MapGeoJSONFeature[] }) => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
const features = map.queryRenderedFeatures(e.point, {
|
||||||
|
layers: ["all-incidents"],
|
||||||
|
});
|
||||||
|
if (!features || features.length === 0) return;
|
||||||
|
|
||||||
|
// Stop event propagation
|
||||||
|
e.originalEvent.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
isInteractingWithMarker.current = true;
|
||||||
|
|
||||||
|
const incident = features[0];
|
||||||
|
if (!incident.properties) return;
|
||||||
|
|
||||||
|
const props = incident
|
||||||
|
.properties as unknown as IIncidentFeatureProperties;
|
||||||
|
|
||||||
|
const IincidentDetails: IIncidentDetails = {
|
||||||
|
id: props.id,
|
||||||
|
description: props.description,
|
||||||
|
category: props.category,
|
||||||
|
district: props.district,
|
||||||
|
district_id: props.district_id,
|
||||||
|
year: props.year,
|
||||||
|
month: props.month,
|
||||||
|
address: props.address,
|
||||||
|
latitude: props.latitude,
|
||||||
|
longitude: props.longitude,
|
||||||
|
timestamp: new Date(props.timestamp || Date.now()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fly to the incident location
|
||||||
|
map.flyTo({
|
||||||
|
center: [IincidentDetails.longitude, IincidentDetails.latitude],
|
||||||
|
zoom: ZOOM_3D,
|
||||||
|
bearing: BASE_BEARING,
|
||||||
|
pitch: PITCH_3D,
|
||||||
|
duration: BASE_DURATION,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set selected incident for the popup
|
||||||
|
setSelectedIncident(IincidentDetails);
|
||||||
|
|
||||||
|
// Reset the flag after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
isInteractingWithMarker.current = false;
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
[map],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle popup close
|
||||||
|
const handleClosePopup = useCallback(() => {
|
||||||
|
if (!map) return;
|
||||||
|
|
||||||
|
map.easeTo({
|
||||||
|
zoom: BASE_ZOOM,
|
||||||
|
bearing: BASE_BEARING,
|
||||||
|
pitch: BASE_PITCH,
|
||||||
|
duration: BASE_DURATION,
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedIncident(null);
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
// Effect to manage layer visibility consistently
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = manageLayerVisibility(map, LAYER_IDS, visible, () => {
|
||||||
|
// When layers become invisible, close any open popup
|
||||||
|
if (!visible) setSelectedIncident(null);
|
||||||
|
|
||||||
|
// Cancel animation frame when hiding the layer
|
||||||
|
if (!visible && animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
}, [visible, map]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map || !visible) return;
|
||||||
|
|
||||||
|
// Convert incidents to GeoJSON format
|
||||||
|
const allIncidents = crimes.flatMap((crime) => {
|
||||||
|
return crime.crime_incidents
|
||||||
|
.filter((incident) =>
|
||||||
|
// Apply category filter if specified
|
||||||
|
(filterCategory === "all" ||
|
||||||
|
incident.crime_categories?.name === filterCategory) &&
|
||||||
|
// Make sure we have valid location data
|
||||||
|
incident.locations?.latitude &&
|
||||||
|
incident.locations?.longitude
|
||||||
|
)
|
||||||
|
.map((incident) => ({
|
||||||
|
type: "Feature" as const,
|
||||||
|
geometry: {
|
||||||
|
type: "Point" as const,
|
||||||
|
coordinates: [
|
||||||
|
incident.locations!.longitude,
|
||||||
|
incident.locations!.latitude,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
id: incident.id,
|
||||||
|
description: incident.description || "No description",
|
||||||
|
timestamp: incident.timestamp?.toString() ||
|
||||||
|
new Date().toString(),
|
||||||
|
category: incident.crime_categories?.name || "Unknown",
|
||||||
|
district: crime.districts?.name || "Unknown",
|
||||||
|
district_id: crime.district_id,
|
||||||
|
year: crime.year,
|
||||||
|
month: crime.month,
|
||||||
|
address: incident.locations?.address || null,
|
||||||
|
latitude: incident.locations!.latitude,
|
||||||
|
longitude: incident.locations!.longitude,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const incidentsGeoJSON = {
|
||||||
|
type: "FeatureCollection" as const,
|
||||||
|
features: allIncidents,
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupLayersAndSources = () => {
|
||||||
|
try {
|
||||||
|
// Check if source already exists and update it
|
||||||
|
if (map.getSource("all-incidents-source")) {
|
||||||
|
const source = map.getSource(
|
||||||
|
"all-incidents-source",
|
||||||
|
) as mapboxgl.GeoJSONSource;
|
||||||
|
source.setData(incidentsGeoJSON);
|
||||||
|
} else {
|
||||||
|
// Add source if it doesn't exist
|
||||||
|
map.addSource("all-incidents-source", {
|
||||||
|
type: "geojson",
|
||||||
|
data: incidentsGeoJSON,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get first symbol layer for insertion order
|
||||||
|
const layers = map.getStyle().layers;
|
||||||
|
let firstSymbolId: string | undefined;
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.type === "symbol") {
|
||||||
|
firstSymbolId = layer.id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulsing circle effect for very recent incidents
|
||||||
|
map.addLayer({
|
||||||
|
id: "all-incidents-pulse",
|
||||||
|
type: "circle",
|
||||||
|
source: "all-incidents-source",
|
||||||
|
paint: {
|
||||||
|
"circle-radius": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
10,
|
||||||
|
10,
|
||||||
|
15,
|
||||||
|
20,
|
||||||
|
],
|
||||||
|
"circle-color": [
|
||||||
|
"match",
|
||||||
|
["get", "category"],
|
||||||
|
"Theft",
|
||||||
|
"#FF5733",
|
||||||
|
"Assault",
|
||||||
|
"#C70039",
|
||||||
|
"Robbery",
|
||||||
|
"#900C3F",
|
||||||
|
"Burglary",
|
||||||
|
"#581845",
|
||||||
|
"Fraud",
|
||||||
|
"#FFC300",
|
||||||
|
"Homicide",
|
||||||
|
"#FF0000",
|
||||||
|
// Default color for other categories
|
||||||
|
"#2874A6",
|
||||||
|
],
|
||||||
|
"circle-opacity": 0.4,
|
||||||
|
"circle-blur": 0.6,
|
||||||
|
},
|
||||||
|
}, firstSymbolId);
|
||||||
|
|
||||||
|
// Background circle for all incidents
|
||||||
|
map.addLayer({
|
||||||
|
id: "all-incidents-circles",
|
||||||
|
type: "circle",
|
||||||
|
source: "all-incidents-source",
|
||||||
|
paint: {
|
||||||
|
"circle-radius": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
10,
|
||||||
|
5,
|
||||||
|
15,
|
||||||
|
10,
|
||||||
|
],
|
||||||
|
"circle-color": [
|
||||||
|
"match",
|
||||||
|
["get", "category"],
|
||||||
|
"Theft",
|
||||||
|
"#FF5733",
|
||||||
|
"Assault",
|
||||||
|
"#C70039",
|
||||||
|
"Robbery",
|
||||||
|
"#900C3F",
|
||||||
|
"Burglary",
|
||||||
|
"#581845",
|
||||||
|
"Fraud",
|
||||||
|
"#FFC300",
|
||||||
|
"Homicide",
|
||||||
|
"#FF0000",
|
||||||
|
// Default color for other categories
|
||||||
|
"#2874A6",
|
||||||
|
],
|
||||||
|
"circle-stroke-width": 1,
|
||||||
|
"circle-stroke-color": "#FFFFFF",
|
||||||
|
"circle-opacity": 0.6,
|
||||||
|
},
|
||||||
|
}, firstSymbolId);
|
||||||
|
|
||||||
|
// Main incident point
|
||||||
|
map.addLayer({
|
||||||
|
id: "all-incidents",
|
||||||
|
type: "circle",
|
||||||
|
source: "all-incidents-source",
|
||||||
|
paint: {
|
||||||
|
"circle-radius": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
10,
|
||||||
|
3,
|
||||||
|
15,
|
||||||
|
6,
|
||||||
|
],
|
||||||
|
"circle-color": [
|
||||||
|
"match",
|
||||||
|
["get", "category"],
|
||||||
|
"Theft",
|
||||||
|
"#FF5733",
|
||||||
|
"Assault",
|
||||||
|
"#C70039",
|
||||||
|
"Robbery",
|
||||||
|
"#900C3F",
|
||||||
|
"Burglary",
|
||||||
|
"#581845",
|
||||||
|
"Fraud",
|
||||||
|
"#FFC300",
|
||||||
|
"Homicide",
|
||||||
|
"#FF0000",
|
||||||
|
// Default color for other categories
|
||||||
|
"#2874A6",
|
||||||
|
],
|
||||||
|
"circle-stroke-width": 1,
|
||||||
|
"circle-stroke-color": "#FFFFFF",
|
||||||
|
"circle-opacity": 1,
|
||||||
|
},
|
||||||
|
}, firstSymbolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add mouse events
|
||||||
|
map.on("mouseenter", "all-incidents", () => {
|
||||||
|
map.getCanvas().style.cursor = "pointer";
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on("mouseleave", "all-incidents", () => {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
map.on("click", "all-incidents", handleIncidentClick);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error setting up all incidents layer:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set up layers when the map is ready
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
setupLayersAndSources();
|
||||||
|
} else {
|
||||||
|
map.once("load", setupLayersAndSources);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the pulse animation effect
|
||||||
|
const animatePulse = () => {
|
||||||
|
if (!map || !visible || !map.getLayer("all-incidents-pulse")) {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pulseSize = 10 + 5 * Math.sin(Date.now() / 500);
|
||||||
|
|
||||||
|
try {
|
||||||
|
map.setPaintProperty("all-incidents-pulse", "circle-radius", [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
10,
|
||||||
|
pulseSize,
|
||||||
|
15,
|
||||||
|
pulseSize * 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animatePulse);
|
||||||
|
} catch (error) {
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(animatePulse);
|
||||||
|
|
||||||
|
// Clean up event listeners and animation
|
||||||
|
return () => {
|
||||||
|
if (map) {
|
||||||
|
map.off("click", "all-incidents", handleIncidentClick);
|
||||||
|
map.off("mouseenter", "all-incidents", () => {
|
||||||
|
map.getCanvas().style.cursor = "pointer";
|
||||||
|
});
|
||||||
|
map.off("mouseleave", "all-incidents", () => {
|
||||||
|
map.getCanvas().style.cursor = "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [map, visible, crimes, filterCategory, handleIncidentClick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{selectedIncident && (
|
||||||
|
<IncidentPopup
|
||||||
|
longitude={selectedIncident.longitude}
|
||||||
|
latitude={selectedIncident.latitude}
|
||||||
|
onClose={handleClosePopup}
|
||||||
|
incident={selectedIncident}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,260 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useEffect, useCallback, useRef, useState } from "react"
|
|
||||||
import type { ICrimes } from "@/app/_utils/types/crimes"
|
|
||||||
|
|
||||||
interface HistoricalIncidentsLayerProps {
|
|
||||||
visible?: boolean
|
|
||||||
map: any
|
|
||||||
crimes?: ICrimes[]
|
|
||||||
filterCategory?: string | "all"
|
|
||||||
focusedDistrictId?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HistoricalIncidentsLayer({
|
|
||||||
visible = false,
|
|
||||||
map,
|
|
||||||
crimes = [],
|
|
||||||
filterCategory = "all",
|
|
||||||
focusedDistrictId,
|
|
||||||
}: HistoricalIncidentsLayerProps) {
|
|
||||||
const isInteractingWithMarker = useRef(false);
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
const startYear = 2020;
|
|
||||||
const [yearColors, setYearColors] = useState<Record<number, string>>({});
|
|
||||||
|
|
||||||
// Generate colors for each year from 2020 to current year
|
|
||||||
useEffect(() => {
|
|
||||||
const colors: Record<number, string> = {};
|
|
||||||
const yearCount = currentYear - startYear + 1;
|
|
||||||
|
|
||||||
for (let i = 0; i < yearCount; i++) {
|
|
||||||
const year = startYear + i;
|
|
||||||
// Generate a color gradient from red (2020) to blue (current year)
|
|
||||||
const red = Math.floor(255 - (i * 255 / yearCount));
|
|
||||||
const blue = Math.floor(i * 255 / yearCount);
|
|
||||||
colors[year] = `rgb(${red}, 70, ${blue})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
setYearColors(colors);
|
|
||||||
}, [currentYear]);
|
|
||||||
|
|
||||||
const handleIncidentClick = useCallback(
|
|
||||||
(e: any) => {
|
|
||||||
if (!map) return;
|
|
||||||
|
|
||||||
const features = map.queryRenderedFeatures(e.point, { layers: ["historical-incidents"] });
|
|
||||||
if (!features || features.length === 0) return;
|
|
||||||
|
|
||||||
isInteractingWithMarker.current = true;
|
|
||||||
|
|
||||||
const incident = features[0];
|
|
||||||
if (!incident.properties) return;
|
|
||||||
|
|
||||||
e.originalEvent.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const incidentDetails = {
|
|
||||||
id: incident.properties.id,
|
|
||||||
district: incident.properties.district,
|
|
||||||
category: incident.properties.category,
|
|
||||||
type: incident.properties.incidentType,
|
|
||||||
description: incident.properties.description,
|
|
||||||
status: incident.properties?.status || "Unknown",
|
|
||||||
longitude: (incident.geometry as any).coordinates[0],
|
|
||||||
latitude: (incident.geometry as any).coordinates[1],
|
|
||||||
timestamp: new Date(incident.properties.timestamp || Date.now()),
|
|
||||||
year: incident.properties.year,
|
|
||||||
};
|
|
||||||
|
|
||||||
// console.log("Historical incident clicked:", incidentDetails);
|
|
||||||
|
|
||||||
// Ensure markers stay visible when clicking on them
|
|
||||||
if (map.getLayer("historical-incidents")) {
|
|
||||||
map.setLayoutProperty("historical-incidents", "visibility", "visible");
|
|
||||||
}
|
|
||||||
|
|
||||||
// First fly to the incident location
|
|
||||||
map.flyTo({
|
|
||||||
center: [incidentDetails.longitude, incidentDetails.latitude],
|
|
||||||
zoom: 15,
|
|
||||||
bearing: 0,
|
|
||||||
pitch: 45,
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then dispatch the incident_click event to show the popup
|
|
||||||
const customEvent = new CustomEvent("incident_click", {
|
|
||||||
detail: incidentDetails,
|
|
||||||
bubbles: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
map.getCanvas().dispatchEvent(customEvent);
|
|
||||||
document.dispatchEvent(customEvent);
|
|
||||||
|
|
||||||
// Reset the flag after a delay to allow the event to process
|
|
||||||
setTimeout(() => {
|
|
||||||
isInteractingWithMarker.current = false;
|
|
||||||
}, 5000);
|
|
||||||
},
|
|
||||||
[map]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!map || !visible) return;
|
|
||||||
|
|
||||||
// console.log("Setting up historical incidents layer");
|
|
||||||
|
|
||||||
// Filter incidents from 2020 to current year
|
|
||||||
const historicalData = {
|
|
||||||
type: "FeatureCollection" as const,
|
|
||||||
features: crimes.flatMap((crime) =>
|
|
||||||
crime.crime_incidents
|
|
||||||
.filter(
|
|
||||||
(incident) => {
|
|
||||||
const incidentYear = incident.timestamp ? new Date(incident.timestamp).getFullYear() : null;
|
|
||||||
return (
|
|
||||||
(filterCategory === "all" || incident.crime_categories.name === filterCategory) &&
|
|
||||||
incident.locations &&
|
|
||||||
typeof incident.locations.longitude === "number" &&
|
|
||||||
typeof incident.locations.latitude === "number" &&
|
|
||||||
incidentYear !== null &&
|
|
||||||
incidentYear >= startYear &&
|
|
||||||
incidentYear <= currentYear
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.map((incident) => {
|
|
||||||
const incidentYear = incident.timestamp ? new Date(incident.timestamp).getFullYear() : currentYear;
|
|
||||||
return {
|
|
||||||
type: "Feature" as const,
|
|
||||||
geometry: {
|
|
||||||
type: "Point" as const,
|
|
||||||
coordinates: [incident.locations.longitude, incident.locations.latitude],
|
|
||||||
},
|
|
||||||
properties: {
|
|
||||||
id: incident.id,
|
|
||||||
district: crime.districts.name,
|
|
||||||
district_id: crime.district_id,
|
|
||||||
category: incident.crime_categories.name,
|
|
||||||
incidentType: incident.crime_categories.type || "",
|
|
||||||
description: incident.description,
|
|
||||||
status: incident.status || "",
|
|
||||||
timestamp: incident.timestamp ? incident.timestamp.toString() : "",
|
|
||||||
year: incidentYear,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
// console.log(`Found ${historicalData.features.length} historical incidents from 2020 to ${currentYear}`);
|
|
||||||
|
|
||||||
const setupLayerAndSource = () => {
|
|
||||||
try {
|
|
||||||
// Check if source exists and update it
|
|
||||||
if (map.getSource("historical-incidents-source")) {
|
|
||||||
(map.getSource("historical-incidents-source") as any).setData(historicalData);
|
|
||||||
} else {
|
|
||||||
// If not, add source
|
|
||||||
map.addSource("historical-incidents-source", {
|
|
||||||
type: "geojson",
|
|
||||||
data: historicalData,
|
|
||||||
// No clustering configuration
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find first symbol layer for proper layering
|
|
||||||
const layers = map.getStyle().layers;
|
|
||||||
let firstSymbolId: string | undefined;
|
|
||||||
for (const layer of layers) {
|
|
||||||
if (layer.type === "symbol") {
|
|
||||||
firstSymbolId = layer.id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Style for year-based coloring
|
|
||||||
const circleColorExpression: any[] = [
|
|
||||||
"match",
|
|
||||||
["get", "year"],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add entries for each year and its color
|
|
||||||
Object.entries(yearColors).forEach(([year, color]) => {
|
|
||||||
circleColorExpression.push(parseInt(year), color);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Default color for unknown years
|
|
||||||
circleColorExpression.push("#888888");
|
|
||||||
|
|
||||||
// Check if layer exists already
|
|
||||||
if (!map.getLayer("historical-incidents")) {
|
|
||||||
map.addLayer({
|
|
||||||
id: "historical-incidents",
|
|
||||||
type: "circle",
|
|
||||||
source: "historical-incidents-source",
|
|
||||||
paint: {
|
|
||||||
"circle-color": circleColorExpression,
|
|
||||||
"circle-radius": [
|
|
||||||
"interpolate",
|
|
||||||
["linear"],
|
|
||||||
["zoom"],
|
|
||||||
7, 2, // Smaller circles at lower zoom levels
|
|
||||||
12, 4,
|
|
||||||
15, 6, // Smaller maximum size
|
|
||||||
],
|
|
||||||
"circle-stroke-width": 1,
|
|
||||||
"circle-stroke-color": "#ffffff",
|
|
||||||
"circle-opacity": 0.8,
|
|
||||||
},
|
|
||||||
layout: {
|
|
||||||
visibility: visible ? "visible" : "none",
|
|
||||||
}
|
|
||||||
}, firstSymbolId);
|
|
||||||
|
|
||||||
// Add mouse events
|
|
||||||
map.on("mouseenter", "historical-incidents", () => {
|
|
||||||
map.getCanvas().style.cursor = "pointer";
|
|
||||||
});
|
|
||||||
|
|
||||||
map.on("mouseleave", "historical-incidents", () => {
|
|
||||||
map.getCanvas().style.cursor = "";
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Update existing layer visibility
|
|
||||||
map.setLayoutProperty("historical-incidents", "visibility", visible ? "visible" : "none");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure click handler is properly registered
|
|
||||||
map.off("click", "historical-incidents", handleIncidentClick);
|
|
||||||
map.on("click", "historical-incidents", handleIncidentClick);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error setting up historical incidents layer:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if style is loaded and set up layer accordingly
|
|
||||||
if (map.isStyleLoaded()) {
|
|
||||||
setupLayerAndSource();
|
|
||||||
} else {
|
|
||||||
map.once("style.load", setupLayerAndSource);
|
|
||||||
|
|
||||||
// Fallback
|
|
||||||
setTimeout(() => {
|
|
||||||
if (map.isStyleLoaded()) {
|
|
||||||
setupLayerAndSource();
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (map) {
|
|
||||||
map.off("click", "historical-incidents", handleIncidentClick);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [map, visible, crimes, filterCategory, handleIncidentClick, currentYear, yearColors]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
|
@ -1,77 +1,89 @@
|
||||||
"use client"
|
"use client";
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useMap } from "react-map-gl/mapbox"
|
import { useMap } from "react-map-gl/mapbox";
|
||||||
import { BASE_BEARING, BASE_DURATION, BASE_PITCH, BASE_ZOOM, MAPBOX_TILESET_ID, PITCH_3D, ZOOM_3D } from "@/app/_utils/const/map"
|
import {
|
||||||
import DistrictPopup from "../pop-up/district-popup"
|
BASE_BEARING,
|
||||||
import DistrictExtrusionLayer from "./district-extrusion-layer"
|
BASE_DURATION,
|
||||||
import ClusterLayer from "./cluster-layer"
|
BASE_PITCH,
|
||||||
import HeatmapLayer from "./heatmap-layer"
|
BASE_ZOOM,
|
||||||
import TimelineLayer from "./timeline-layer"
|
MAPBOX_TILESET_ID,
|
||||||
|
PITCH_3D,
|
||||||
|
ZOOM_3D,
|
||||||
|
} from "@/app/_utils/const/map";
|
||||||
|
import DistrictPopup from "../pop-up/district-popup";
|
||||||
|
import DistrictExtrusionLayer from "./district-extrusion-layer";
|
||||||
|
import ClusterLayer from "./cluster-layer";
|
||||||
|
import HeatmapLayer from "./heatmap-layer";
|
||||||
|
import TimelineLayer from "./timeline-layer";
|
||||||
|
|
||||||
import type { ICrimes, IIncidentLogs } from "@/app/_utils/types/crimes"
|
import type { ICrimes, IIncidentLogs } from "@/app/_utils/types/crimes";
|
||||||
import type { IDistrictFeature } from "@/app/_utils/types/map"
|
import type { IDistrictFeature } from "@/app/_utils/types/map";
|
||||||
import { createFillColorExpression, getCrimeRateColor, processCrimeDataByDistrict } from "@/app/_utils/map"
|
import {
|
||||||
import UnclusteredPointLayer from "./uncluster-layer"
|
createFillColorExpression,
|
||||||
|
getCrimeRateColor,
|
||||||
|
processCrimeDataByDistrict,
|
||||||
|
} from "@/app/_utils/map";
|
||||||
|
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner";
|
||||||
import type { ITooltipsControl } from "../controls/top/tooltips"
|
import type { ITooltipsControl } from "../controls/top/tooltips";
|
||||||
import type { IUnits } from "@/app/_utils/types/units"
|
import type { IUnits } from "@/app/_utils/types/units";
|
||||||
import UnitsLayer from "./units-layer"
|
import UnitsLayer from "./units-layer";
|
||||||
import DistrictFillLineLayer from "./district-layer"
|
import DistrictFillLineLayer from "./district-layer";
|
||||||
|
|
||||||
import TimezoneLayer from "./timezone"
|
import TimezoneLayer from "./timezone";
|
||||||
import FaultLinesLayer from "./fault-lines"
|
import FaultLinesLayer from "./fault-lines";
|
||||||
import RecentIncidentsLayer from "./recent-incidents-layer"
|
import RecentIncidentsLayer from "./recent-incidents-layer";
|
||||||
import IncidentPopup from "../pop-up/incident-popup"
|
import IncidentPopup from "../pop-up/incident-popup";
|
||||||
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility"
|
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility";
|
||||||
|
import AllIncidentsLayer from "./all-incidents-layer";
|
||||||
|
|
||||||
// Interface for crime incident
|
// Interface for crime incident
|
||||||
interface ICrimeIncident {
|
interface ICrimeIncident {
|
||||||
id: string
|
id: string;
|
||||||
district?: string
|
district?: string;
|
||||||
category?: string
|
category?: string;
|
||||||
type_category?: string | null
|
type_category?: string | null;
|
||||||
description?: string
|
description?: string;
|
||||||
status: string
|
status: string;
|
||||||
address?: string | null
|
address?: string | null;
|
||||||
timestamp?: Date
|
timestamp?: Date;
|
||||||
latitude?: number
|
latitude?: number;
|
||||||
longitude?: number
|
longitude?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// District layer props
|
// District layer props
|
||||||
export interface IDistrictLayerProps {
|
export interface IDistrictLayerProps {
|
||||||
visible?: boolean
|
visible?: boolean;
|
||||||
onClick?: (feature: IDistrictFeature) => void
|
onClick?: (feature: IDistrictFeature) => void;
|
||||||
onDistrictClick?: (feature: IDistrictFeature) => void
|
onDistrictClick?: (feature: IDistrictFeature) => void;
|
||||||
map?: any
|
map?: any;
|
||||||
year: string
|
year: string;
|
||||||
month: string
|
month: string;
|
||||||
filterCategory: string | "all"
|
filterCategory: string | "all";
|
||||||
crimes: ICrimes[]
|
crimes: ICrimes[];
|
||||||
units?: IUnits[]
|
units?: IUnits[];
|
||||||
tilesetId?: string
|
tilesetId?: string;
|
||||||
focusedDistrictId?: string | null
|
focusedDistrictId?: string | null;
|
||||||
setFocusedDistrictId?: (id: string | null) => void
|
setFocusedDistrictId?: (id: string | null) => void;
|
||||||
crimeDataByDistrict?: Record<string, any>
|
crimeDataByDistrict?: Record<string, any>;
|
||||||
showFill?: boolean
|
showFill?: boolean;
|
||||||
activeControl?: ITooltipsControl
|
activeControl?: ITooltipsControl;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LayersProps {
|
interface LayersProps {
|
||||||
visible?: boolean
|
visible?: boolean;
|
||||||
crimes: ICrimes[]
|
crimes: ICrimes[];
|
||||||
units?: IUnits[]
|
units?: IUnits[];
|
||||||
recentIncidents: IIncidentLogs[]
|
recentIncidents: IIncidentLogs[];
|
||||||
year: string
|
year: string;
|
||||||
month: string
|
month: string;
|
||||||
filterCategory: string | "all"
|
filterCategory: string | "all";
|
||||||
activeControl: ITooltipsControl
|
activeControl: ITooltipsControl;
|
||||||
tilesetId?: string
|
tilesetId?: string;
|
||||||
useAllData?: boolean
|
useAllData?: boolean;
|
||||||
showEWS?: boolean
|
showEWS?: boolean;
|
||||||
sourceType?: string
|
sourceType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layers({
|
export default function Layers({
|
||||||
|
@ -88,32 +100,38 @@ export default function Layers({
|
||||||
showEWS = true,
|
showEWS = true,
|
||||||
sourceType = "cbt",
|
sourceType = "cbt",
|
||||||
}: LayersProps) {
|
}: LayersProps) {
|
||||||
const animationRef = useRef<number | null>(null)
|
const animationRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const { current: map } = useMap()
|
const { current: map } = useMap();
|
||||||
|
|
||||||
if (!map) {
|
if (!map) {
|
||||||
toast.error("Map not found")
|
toast.error("Map not found");
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapboxMap = map.getMap()
|
const mapboxMap = map.getMap();
|
||||||
|
|
||||||
const [selectedDistrict, setSelectedDistrict] = useState<IDistrictFeature | null>(null)
|
const [selectedDistrict, setSelectedDistrict] = useState<
|
||||||
const [selectedIncident, setSelectedIncident] = useState<ICrimeIncident | null>(null)
|
IDistrictFeature | null
|
||||||
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null)
|
>(null);
|
||||||
const selectedDistrictRef = useRef<IDistrictFeature | null>(null)
|
const [selectedIncident, setSelectedIncident] = useState<
|
||||||
|
ICrimeIncident | null
|
||||||
|
>(null);
|
||||||
|
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const selectedDistrictRef = useRef<IDistrictFeature | null>(null);
|
||||||
// Track if we're currently interacting with a marker to prevent district selection
|
// Track if we're currently interacting with a marker to prevent district selection
|
||||||
const isInteractingWithMarker = useRef<boolean>(false)
|
const isInteractingWithMarker = useRef<boolean>(false);
|
||||||
|
|
||||||
const crimeDataByDistrict = processCrimeDataByDistrict(crimes)
|
const crimeDataByDistrict = processCrimeDataByDistrict(crimes);
|
||||||
|
|
||||||
const handlePopupClose = useCallback(() => {
|
const handlePopupClose = useCallback(() => {
|
||||||
selectedDistrictRef.current = null
|
selectedDistrictRef.current = null;
|
||||||
setSelectedDistrict(null)
|
setSelectedDistrict(null);
|
||||||
setSelectedIncident(null)
|
setSelectedIncident(null);
|
||||||
setFocusedDistrictId(null)
|
setFocusedDistrictId(null);
|
||||||
isInteractingWithMarker.current = false
|
isInteractingWithMarker.current = false;
|
||||||
|
|
||||||
if (map) {
|
if (map) {
|
||||||
map.easeTo({
|
map.easeTo({
|
||||||
|
@ -122,69 +140,101 @@ export default function Layers({
|
||||||
bearing: BASE_BEARING,
|
bearing: BASE_BEARING,
|
||||||
duration: BASE_DURATION,
|
duration: BASE_DURATION,
|
||||||
easing: (t) => t * (2 - t),
|
easing: (t) => t * (2 - t),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (map.getLayer("clusters")) {
|
if (map.getLayer("clusters")) {
|
||||||
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
|
map.getMap().setLayoutProperty(
|
||||||
|
"clusters",
|
||||||
|
"visibility",
|
||||||
|
"visible",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (map.getLayer("unclustered-point")) {
|
if (map.getLayer("unclustered-point")) {
|
||||||
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
|
map.getMap().setLayoutProperty(
|
||||||
|
"unclustered-point",
|
||||||
|
"visibility",
|
||||||
|
"visible",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (map.getLayer("district-fill")) {
|
if (map.getLayer("district-fill")) {
|
||||||
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
|
const fillColorExpression = createFillColorExpression(
|
||||||
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
|
null,
|
||||||
|
crimeDataByDistrict,
|
||||||
|
);
|
||||||
|
map.getMap().setPaintProperty(
|
||||||
|
"district-fill",
|
||||||
|
"fill-color",
|
||||||
|
fillColorExpression as any,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [map, crimeDataByDistrict])
|
}, [map, crimeDataByDistrict]);
|
||||||
|
|
||||||
const animateExtrusionDown = () => {
|
const animateExtrusionDown = () => {
|
||||||
if (!map || !map.getLayer("district-extrusion") || !focusedDistrictId) {
|
if (!map || !map.getLayer("district-extrusion") || !focusedDistrictId) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (animationRef.current) {
|
if (animationRef.current) {
|
||||||
cancelAnimationFrame(animationRef.current)
|
cancelAnimationFrame(animationRef.current);
|
||||||
animationRef.current = null
|
animationRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current height from the layer (default to 800 if not found)
|
// Get the current height from the layer (default to 800 if not found)
|
||||||
let currentHeight = 800
|
let currentHeight = 800;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const paint = map.getPaintProperty("district-extrusion", "fill-extrusion-height")
|
const paint = map.getPaintProperty(
|
||||||
|
"district-extrusion",
|
||||||
|
"fill-extrusion-height",
|
||||||
|
);
|
||||||
if (Array.isArray(paint) && paint.length > 0) {
|
if (Array.isArray(paint) && paint.length > 0) {
|
||||||
// Try to extract the current height from the expression
|
// Try to extract the current height from the expression
|
||||||
const idx = paint.findIndex((v) => v === focusedDistrictId)
|
const idx = paint.findIndex((v) => v === focusedDistrictId);
|
||||||
if (idx !== -1 && typeof paint[idx + 1] === "number") {
|
if (idx !== -1 && typeof paint[idx + 1] === "number") {
|
||||||
currentHeight = paint[idx + 1]
|
currentHeight = paint[idx + 1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// fallback to default
|
// fallback to default
|
||||||
}
|
}
|
||||||
|
|
||||||
const startHeight = currentHeight
|
const startHeight = currentHeight;
|
||||||
const targetHeight = 0
|
const targetHeight = 0;
|
||||||
const duration = 700
|
const duration = 700;
|
||||||
const startTime = performance.now()
|
const startTime = performance.now();
|
||||||
|
|
||||||
const animate = (currentTime: number) => {
|
const animate = (currentTime: number) => {
|
||||||
const elapsed = currentTime - startTime
|
const elapsed = currentTime - startTime;
|
||||||
const progress = Math.min(elapsed / duration, 1)
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
const easedProgress = progress * (2 - progress)
|
const easedProgress = progress * (2 - progress);
|
||||||
const newHeight = startHeight + (targetHeight - startHeight) * easedProgress
|
const newHeight = startHeight +
|
||||||
|
(targetHeight - startHeight) * easedProgress;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-height", [
|
map.getMap().setPaintProperty(
|
||||||
|
"district-extrusion",
|
||||||
|
"fill-extrusion-height",
|
||||||
|
[
|
||||||
"case",
|
"case",
|
||||||
["has", "kode_kec"],
|
["has", "kode_kec"],
|
||||||
["match", ["get", "kode_kec"], focusedDistrictId, newHeight, 0],
|
[
|
||||||
|
"match",
|
||||||
|
["get", "kode_kec"],
|
||||||
|
focusedDistrictId,
|
||||||
|
newHeight,
|
||||||
0,
|
0,
|
||||||
])
|
],
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-color", [
|
map.getMap().setPaintProperty(
|
||||||
|
"district-extrusion",
|
||||||
|
"fill-extrusion-color",
|
||||||
|
[
|
||||||
"case",
|
"case",
|
||||||
["has", "kode_kec"],
|
["has", "kode_kec"],
|
||||||
[
|
[
|
||||||
|
@ -195,40 +245,40 @@ export default function Layers({
|
||||||
"transparent",
|
"transparent",
|
||||||
],
|
],
|
||||||
"transparent",
|
"transparent",
|
||||||
])
|
],
|
||||||
|
);
|
||||||
|
|
||||||
if (progress < 1) {
|
if (progress < 1) {
|
||||||
animationRef.current = requestAnimationFrame(animate)
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
} else {
|
} else {
|
||||||
animationRef.current = null
|
animationRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (animationRef.current) {
|
if (animationRef.current) {
|
||||||
cancelAnimationFrame(animationRef.current)
|
cancelAnimationFrame(animationRef.current);
|
||||||
animationRef.current = null
|
animationRef.current = null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
animationRef.current = requestAnimationFrame(animate)
|
animationRef.current = requestAnimationFrame(animate);
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleCloseDistrictPopup = useCallback(() => {
|
const handleCloseDistrictPopup = useCallback(() => {
|
||||||
animateExtrusionDown()
|
animateExtrusionDown();
|
||||||
handlePopupClose()
|
handlePopupClose();
|
||||||
}, [handlePopupClose, animateExtrusionDown])
|
}, [handlePopupClose, animateExtrusionDown]);
|
||||||
|
|
||||||
const handleDistrictClick = useCallback(
|
const handleDistrictClick = useCallback(
|
||||||
(feature: IDistrictFeature) => {
|
(feature: IDistrictFeature) => {
|
||||||
if (isInteractingWithMarker.current) {
|
if (isInteractingWithMarker.current) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedIncident(null)
|
setSelectedIncident(null);
|
||||||
setSelectedDistrict(feature)
|
setSelectedDistrict(feature);
|
||||||
selectedDistrictRef.current = feature
|
selectedDistrictRef.current = feature;
|
||||||
setFocusedDistrictId(feature.id)
|
setFocusedDistrictId(feature.id);
|
||||||
|
|
||||||
if (map && feature.longitude && feature.latitude) {
|
if (map && feature.longitude && feature.latitude) {
|
||||||
map.flyTo({
|
map.flyTo({
|
||||||
|
@ -238,27 +288,36 @@ export default function Layers({
|
||||||
bearing: BASE_BEARING,
|
bearing: BASE_BEARING,
|
||||||
duration: BASE_DURATION,
|
duration: BASE_DURATION,
|
||||||
easing: (t) => t * (2 - t),
|
easing: (t) => t * (2 - t),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (map.getLayer("clusters")) {
|
if (map.getLayer("clusters")) {
|
||||||
map.getMap().setLayoutProperty("clusters", "visibility", "none")
|
map.getMap().setLayoutProperty(
|
||||||
|
"clusters",
|
||||||
|
"visibility",
|
||||||
|
"none",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (map.getLayer("unclustered-point")) {
|
if (map.getLayer("unclustered-point")) {
|
||||||
map.getMap().setLayoutProperty("unclustered-point", "visibility", "none")
|
map.getMap().setLayoutProperty(
|
||||||
|
"unclustered-point",
|
||||||
|
"visibility",
|
||||||
|
"none",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[map],
|
[map],
|
||||||
)
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapboxMap) return
|
if (!mapboxMap) return;
|
||||||
|
|
||||||
const handleFlyToEvent = (e: Event) => {
|
const handleFlyToEvent = (e: Event) => {
|
||||||
const customEvent = e as CustomEvent
|
const customEvent = e as CustomEvent;
|
||||||
if (!map || !customEvent.detail) return
|
if (!map || !customEvent.detail) return;
|
||||||
|
|
||||||
const { longitude, latitude, zoom, bearing, pitch, duration } = customEvent.detail
|
const { longitude, latitude, zoom, bearing, pitch, duration } =
|
||||||
|
customEvent.detail;
|
||||||
|
|
||||||
map.flyTo({
|
map.flyTo({
|
||||||
center: [longitude, latitude],
|
center: [longitude, latitude],
|
||||||
|
@ -266,49 +325,79 @@ export default function Layers({
|
||||||
bearing: bearing || 0,
|
bearing: bearing || 0,
|
||||||
pitch: pitch || 45,
|
pitch: pitch || 45,
|
||||||
duration: duration || 2000,
|
duration: duration || 2000,
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
mapboxMap.getCanvas().addEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
|
mapboxMap.getCanvas().addEventListener(
|
||||||
|
"mapbox_fly_to",
|
||||||
|
handleFlyToEvent as EventListener,
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (mapboxMap && mapboxMap.getCanvas()) {
|
if (mapboxMap && mapboxMap.getCanvas()) {
|
||||||
mapboxMap.getCanvas().removeEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
|
mapboxMap.getCanvas().removeEventListener(
|
||||||
|
"mapbox_fly_to",
|
||||||
|
handleFlyToEvent as EventListener,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
}, [mapboxMap, map])
|
}, [mapboxMap, map]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDistrictRef.current) {
|
if (selectedDistrictRef.current) {
|
||||||
const districtId = selectedDistrictRef.current.id
|
const districtId = selectedDistrictRef.current.id;
|
||||||
const districtCrime = crimes.find((crime) => crime.district_id === districtId)
|
const districtCrime = crimes.find((crime) =>
|
||||||
|
crime.district_id === districtId
|
||||||
|
);
|
||||||
|
|
||||||
if (districtCrime) {
|
if (districtCrime) {
|
||||||
const selectedYearNum = year ? Number.parseInt(year) : new Date().getFullYear()
|
const selectedYearNum = year
|
||||||
|
? Number.parseInt(year)
|
||||||
|
: new Date().getFullYear();
|
||||||
|
|
||||||
let demographics = districtCrime.districts.demographics?.find((d) => d.year === selectedYearNum)
|
let demographics = districtCrime.districts.demographics?.find((
|
||||||
|
d,
|
||||||
|
) => d.year === selectedYearNum);
|
||||||
|
|
||||||
if (!demographics && districtCrime.districts.demographics?.length) {
|
if (
|
||||||
demographics = districtCrime.districts.demographics.sort((a, b) => b.year - a.year)[0]
|
!demographics &&
|
||||||
|
districtCrime.districts.demographics?.length
|
||||||
|
) {
|
||||||
|
demographics =
|
||||||
|
districtCrime.districts.demographics.sort((a, b) =>
|
||||||
|
b.year - a.year
|
||||||
|
)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
let geographics = districtCrime.districts.geographics?.find((g) => g.year === selectedYearNum)
|
let geographics = districtCrime.districts.geographics?.find((
|
||||||
|
g,
|
||||||
|
) => g.year === selectedYearNum);
|
||||||
|
|
||||||
if (!geographics && districtCrime.districts.geographics?.length) {
|
if (
|
||||||
|
!geographics && districtCrime.districts.geographics?.length
|
||||||
|
) {
|
||||||
const validGeographics = districtCrime.districts.geographics
|
const validGeographics = districtCrime.districts.geographics
|
||||||
.filter((g) => g.year !== null)
|
.filter((g) => g.year !== null)
|
||||||
.sort((a, b) => (b.year || 0) - (a.year || 0))
|
.sort((a, b) => (b.year || 0) - (a.year || 0));
|
||||||
|
|
||||||
geographics = validGeographics.length > 0 ? validGeographics[0] : districtCrime.districts.geographics[0]
|
geographics = validGeographics.length > 0
|
||||||
|
? validGeographics[0]
|
||||||
|
: districtCrime.districts.geographics[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!demographics || !geographics) {
|
if (!demographics || !geographics) {
|
||||||
console.error("Missing district data:", { demographics, geographics })
|
console.error("Missing district data:", {
|
||||||
return
|
demographics,
|
||||||
|
geographics,
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const crime_incidents = districtCrime.crime_incidents
|
const crime_incidents = districtCrime.crime_incidents
|
||||||
.filter((incident) => filterCategory === "all" || incident.crime_categories.name === filterCategory)
|
.filter((incident) =>
|
||||||
|
filterCategory === "all" ||
|
||||||
|
incident.crime_categories.name === filterCategory
|
||||||
|
)
|
||||||
.map((incident) => ({
|
.map((incident) => ({
|
||||||
id: incident.id,
|
id: incident.id,
|
||||||
timestamp: incident.timestamp,
|
timestamp: incident.timestamp,
|
||||||
|
@ -319,12 +408,14 @@ export default function Layers({
|
||||||
address: incident.locations.address || "",
|
address: incident.locations.address || "",
|
||||||
latitude: incident.locations.latitude,
|
latitude: incident.locations.latitude,
|
||||||
longitude: incident.locations.longitude,
|
longitude: incident.locations.longitude,
|
||||||
}))
|
}));
|
||||||
|
|
||||||
const updatedDistrict: IDistrictFeature = {
|
const updatedDistrict: IDistrictFeature = {
|
||||||
...selectedDistrictRef.current,
|
...selectedDistrictRef.current,
|
||||||
number_of_crime: crimeDataByDistrict[districtId]?.number_of_crime || 0,
|
number_of_crime:
|
||||||
level: crimeDataByDistrict[districtId]?.level || selectedDistrictRef.current.level,
|
crimeDataByDistrict[districtId]?.number_of_crime || 0,
|
||||||
|
level: crimeDataByDistrict[districtId]?.level ||
|
||||||
|
selectedDistrictRef.current.level,
|
||||||
demographics: {
|
demographics: {
|
||||||
number_of_unemployed: demographics.number_of_unemployed,
|
number_of_unemployed: demographics.number_of_unemployed,
|
||||||
population: demographics.population,
|
population: demographics.population,
|
||||||
|
@ -341,60 +432,84 @@ export default function Layers({
|
||||||
crime_incidents,
|
crime_incidents,
|
||||||
selectedYear: year,
|
selectedYear: year,
|
||||||
selectedMonth: month,
|
selectedMonth: month,
|
||||||
}
|
};
|
||||||
|
|
||||||
selectedDistrictRef.current = updatedDistrict
|
selectedDistrictRef.current = updatedDistrict;
|
||||||
|
|
||||||
setSelectedDistrict((prevDistrict) => {
|
setSelectedDistrict((prevDistrict) => {
|
||||||
if (
|
if (
|
||||||
prevDistrict?.id === updatedDistrict.id &&
|
prevDistrict?.id === updatedDistrict.id &&
|
||||||
prevDistrict?.selectedYear === updatedDistrict.selectedYear &&
|
prevDistrict?.selectedYear ===
|
||||||
prevDistrict?.selectedMonth === updatedDistrict.selectedMonth
|
updatedDistrict.selectedYear &&
|
||||||
|
prevDistrict?.selectedMonth ===
|
||||||
|
updatedDistrict.selectedMonth
|
||||||
) {
|
) {
|
||||||
return prevDistrict
|
return prevDistrict;
|
||||||
}
|
}
|
||||||
return updatedDistrict
|
return updatedDistrict;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [crimes, filterCategory, year, month, crimeDataByDistrict])
|
}, [crimes, filterCategory, year, month, crimeDataByDistrict]);
|
||||||
|
|
||||||
const handleSetFocusedDistrictId = useCallback((id: string | null, isMarkerClick = false) => {
|
const handleSetFocusedDistrictId = useCallback(
|
||||||
|
(id: string | null, isMarkerClick = false) => {
|
||||||
if (isMarkerClick) {
|
if (isMarkerClick) {
|
||||||
isInteractingWithMarker.current = true
|
isInteractingWithMarker.current = true;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isInteractingWithMarker.current = false
|
isInteractingWithMarker.current = false;
|
||||||
}, 1000)
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFocusedDistrictId(id)
|
setFocusedDistrictId(id);
|
||||||
}, [])
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const crimesVisible = activeControl === "incidents"
|
const showHeatmapLayer = activeControl === "heatmap" &&
|
||||||
const showHeatmapLayer = activeControl === "heatmap" && sourceType !== "cbu"
|
sourceType !== "cbu";
|
||||||
const showUnitsLayer = activeControl === "units"
|
const showUnitsLayer = activeControl === "units";
|
||||||
const showTimelineLayer = activeControl === "timeline"
|
const showTimelineLayer = activeControl === "timeline";
|
||||||
const showHistoricalLayer = activeControl === "historical"
|
const showRecentIncidents = activeControl === "recents";
|
||||||
const showRecentIncidents = activeControl === "recents"
|
const showAllIncidents = activeControl === "incidents"; // Use the "incidents" tooltip for all incidents
|
||||||
const showDistrictFill =
|
const showDistrictFill = activeControl === "incidents" ||
|
||||||
activeControl === "incidents" ||
|
|
||||||
activeControl === "clusters" ||
|
activeControl === "clusters" ||
|
||||||
activeControl === "historical" ||
|
activeControl === "recents";
|
||||||
activeControl === "recents"
|
const showIncidentMarkers = activeControl !== "heatmap" &&
|
||||||
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline" && sourceType !== "cbu"
|
activeControl !== "timeline" && sourceType !== "cbu";
|
||||||
|
|
||||||
const shouldShowExtrusion = focusedDistrictId !== null && !isInteractingWithMarker.current
|
const shouldShowExtrusion = focusedDistrictId !== null &&
|
||||||
|
!isInteractingWithMarker.current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapboxMap) return;
|
if (!mapboxMap) return;
|
||||||
|
|
||||||
const recentLayerIds = ["very-recent-incidents-pulse", "recent-incidents-glow", "recent-incidents"];
|
const recentLayerIds = [
|
||||||
|
"very-recent-incidents-pulse",
|
||||||
|
"recent-incidents-glow",
|
||||||
|
"recent-incidents",
|
||||||
|
];
|
||||||
const timelineLayerIds = ["timeline-markers-bg", "timeline-markers"];
|
const timelineLayerIds = ["timeline-markers-bg", "timeline-markers"];
|
||||||
const heatmapLayerIds = ["heatmap-layer"];
|
const heatmapLayerIds = ["heatmap-layer"];
|
||||||
const unitsLayerIds = ["units-points", "incidents-points", "units-labels", "units-connection-lines"];
|
const unitsLayerIds = [
|
||||||
const clusterLayerIds = ["clusters", "cluster-count", "crime-points", "crime-count-labels"];
|
"units-points",
|
||||||
|
"incidents-points",
|
||||||
|
"units-labels",
|
||||||
|
"units-connection-lines",
|
||||||
|
];
|
||||||
|
const clusterLayerIds = [
|
||||||
|
"clusters",
|
||||||
|
"cluster-count",
|
||||||
|
"crime-points",
|
||||||
|
"crime-count-labels",
|
||||||
|
];
|
||||||
const unclusteredLayerIds = ["unclustered-point"];
|
const unclusteredLayerIds = ["unclustered-point"];
|
||||||
|
const allIncidentsLayerIds = [
|
||||||
|
"all-incidents-pulse",
|
||||||
|
"all-incidents-circles",
|
||||||
|
"all-incidents",
|
||||||
|
];
|
||||||
|
|
||||||
if (activeControl !== "recents") {
|
if (activeControl !== "recents") {
|
||||||
manageLayerVisibility(mapboxMap, recentLayerIds, false);
|
manageLayerVisibility(mapboxMap, recentLayerIds, false);
|
||||||
|
@ -416,10 +531,13 @@ export default function Layers({
|
||||||
manageLayerVisibility(mapboxMap, clusterLayerIds, false);
|
manageLayerVisibility(mapboxMap, clusterLayerIds, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeControl !== "incidents" && activeControl !== "recents" && activeControl !== "historical") {
|
if (activeControl !== "incidents") {
|
||||||
manageLayerVisibility(mapboxMap, unclusteredLayerIds, false);
|
manageLayerVisibility(mapboxMap, allIncidentsLayerIds, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeControl !== "incidents" && activeControl !== "recents") {
|
||||||
|
manageLayerVisibility(mapboxMap, unclusteredLayerIds, false);
|
||||||
|
}
|
||||||
}, [activeControl, mapboxMap]);
|
}, [activeControl, mapboxMap]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -450,6 +568,13 @@ export default function Layers({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AllIncidentsLayer
|
||||||
|
visible={showAllIncidents}
|
||||||
|
map={mapboxMap}
|
||||||
|
crimes={crimes}
|
||||||
|
filterCategory={filterCategory}
|
||||||
|
/>
|
||||||
|
|
||||||
<RecentIncidentsLayer
|
<RecentIncidentsLayer
|
||||||
visible={showRecentIncidents}
|
visible={showRecentIncidents}
|
||||||
map={mapboxMap}
|
map={mapboxMap}
|
||||||
|
@ -496,15 +621,8 @@ export default function Layers({
|
||||||
sourceType={sourceType}
|
sourceType={sourceType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<UnclusteredPointLayer
|
{selectedDistrict && !selectedIncident &&
|
||||||
visible={visible && showIncidentMarkers && !focusedDistrictId}
|
!isInteractingWithMarker.current && (
|
||||||
map={mapboxMap}
|
|
||||||
crimes={crimes}
|
|
||||||
filterCategory={filterCategory}
|
|
||||||
focusedDistrictId={focusedDistrictId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{selectedDistrict && !selectedIncident && !isInteractingWithMarker.current && (
|
|
||||||
<DistrictPopup
|
<DistrictPopup
|
||||||
longitude={selectedDistrict.longitude || 0}
|
longitude={selectedDistrict.longitude || 0}
|
||||||
latitude={selectedDistrict.latitude || 0}
|
latitude={selectedDistrict.latitude || 0}
|
||||||
|
@ -520,5 +638,5 @@ export default function Layers({
|
||||||
|
|
||||||
<FaultLinesLayer map={mapboxMap} />
|
<FaultLinesLayer map={mapboxMap} />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
Loading…
Reference in New Issue