437 lines
18 KiB
TypeScript
437 lines
18 KiB
TypeScript
"use client"
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
|
|
import { Skeleton } from "@/app/_components/ui/skeleton"
|
|
import DistrictLayer, { type DistrictFeature } from "./layers/district-layer-old"
|
|
import MapView from "./map"
|
|
import { Button } from "@/app/_components/ui/button"
|
|
import { AlertCircle } from "lucide-react"
|
|
import { getMonthName } from "@/app/_utils/common"
|
|
import { useRef, useState, useCallback, useMemo, useEffect } from "react"
|
|
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
|
|
import { Overlay } from "./overlay"
|
|
import MapLegend from "./legends/map-legend"
|
|
import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
|
|
import MapSelectors from "./controls/map-selector"
|
|
|
|
import { cn } from "@/app/_lib/utils"
|
|
import CrimePopup from "./pop-up/crime-popup"
|
|
import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client"
|
|
import { CrimeTimelapse } from "./controls/bottom/crime-timelapse"
|
|
import { ITooltips } from "./controls/top/tooltips"
|
|
import CrimeSidebar from "./controls/left/sidebar/map-sidebar"
|
|
import Tooltips from "./controls/top/tooltips"
|
|
import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map"
|
|
|
|
// Updated CrimeIncident type to match the structure in crime_incidents
|
|
interface ICrimeIncident {
|
|
id: string
|
|
district?: string
|
|
category?: string
|
|
type_category?: string | null
|
|
description?: string
|
|
status: string
|
|
address?: string | null
|
|
timestamp?: Date
|
|
latitude?: number
|
|
longitude?: number
|
|
}
|
|
|
|
export default function CrimeMap() {
|
|
// State for sidebar
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
|
|
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
|
const [selectedIncident, setSelectedIncident] = useState<ICrimeIncident | null>(null)
|
|
const [showLegend, setShowLegend] = useState<boolean>(true)
|
|
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
|
|
const [selectedYear, setSelectedYear] = useState<number>(2024)
|
|
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
|
|
const [activeControl, setActiveControl] = useState<ITooltips>("incidents")
|
|
const [yearProgress, setYearProgress] = useState(0)
|
|
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
|
|
const [isSearchActive, setIsSearchActive] = useState(false)
|
|
|
|
const mapContainerRef = useRef<HTMLDivElement>(null)
|
|
|
|
// Use the custom fullscreen hook
|
|
const { isFullscreen } = useFullscreen(mapContainerRef)
|
|
|
|
// Get available years
|
|
const {
|
|
data: availableYears,
|
|
isLoading: isYearsLoading,
|
|
error: yearsError
|
|
} = useGetAvailableYears()
|
|
|
|
// Extract all unique categories
|
|
const { data: categoriesData, isLoading: isCategoryLoading } = useGetCrimeCategories()
|
|
|
|
// Transform categories data to string array
|
|
const categories = useMemo(() =>
|
|
categoriesData ? categoriesData.map(category => category.name) : []
|
|
, [categoriesData])
|
|
|
|
// Get all crime data in a single request
|
|
const {
|
|
data: crimes,
|
|
isLoading: isCrimesLoading,
|
|
error: crimesError
|
|
} = useGetCrimes()
|
|
|
|
// Filter crimes based on selected year and month
|
|
const filteredByYearAndMonth = useMemo(() => {
|
|
if (!crimes) return []
|
|
|
|
return crimes.filter((crime) => {
|
|
const yearMatch = crime.year === selectedYear
|
|
|
|
if (selectedMonth === "all") {
|
|
return yearMatch
|
|
} else {
|
|
return yearMatch && crime.month === selectedMonth
|
|
}
|
|
})
|
|
}, [crimes, selectedYear, selectedMonth])
|
|
|
|
// Filter incidents based on selected category
|
|
const filteredCrimes = useMemo(() => {
|
|
if (!filteredByYearAndMonth) return []
|
|
if (selectedCategory === "all") return filteredByYearAndMonth
|
|
|
|
return filteredByYearAndMonth.map((crime) => {
|
|
const filteredIncidents = crime.crime_incidents.filter(
|
|
incident => incident.crime_categories.name === selectedCategory
|
|
)
|
|
|
|
return {
|
|
...crime,
|
|
crime_incidents: filteredIncidents,
|
|
number_of_crime: filteredIncidents.length
|
|
}
|
|
})
|
|
}, [filteredByYearAndMonth, selectedCategory])
|
|
|
|
// Set up event listener for incident clicks from the district layer
|
|
useEffect(() => {
|
|
const handleIncidentClickEvent = (e: CustomEvent) => {
|
|
console.log("Received incident_click event:", e.detail);
|
|
if (!e.detail || !e.detail.id) {
|
|
console.error("Invalid incident data in event:", e.detail);
|
|
return;
|
|
}
|
|
|
|
// Find the incident in filtered crimes data using the id from the event
|
|
let foundIncident: ICrimeIncident | undefined;
|
|
|
|
// Search through all crimes and their incidents to find matching incident
|
|
filteredCrimes.forEach(crime => {
|
|
crime.crime_incidents.forEach(incident => {
|
|
if (incident.id === e.detail.id) {
|
|
// Map the found incident to ICrimeIncident type
|
|
foundIncident = {
|
|
id: incident.id,
|
|
district: crime.districts.name,
|
|
description: incident.description,
|
|
status: incident.status || "unknown",
|
|
timestamp: incident.timestamp,
|
|
category: incident.crime_categories.name,
|
|
type_category: incident.crime_categories.type,
|
|
address: incident.locations.address,
|
|
latitude: incident.locations.latitude,
|
|
longitude: incident.locations.longitude,
|
|
};
|
|
}
|
|
});
|
|
});
|
|
|
|
if (!foundIncident) {
|
|
console.error("Could not find incident with ID:", e.detail.id);
|
|
return;
|
|
}
|
|
|
|
// Validate the coordinates
|
|
if (!foundIncident.latitude || !foundIncident.longitude) {
|
|
console.error("Invalid incident coordinates:", foundIncident);
|
|
return;
|
|
}
|
|
|
|
// When an incident is clicked, clear any selected district
|
|
setSelectedDistrict(null);
|
|
|
|
// Set the selected incident
|
|
setSelectedIncident(foundIncident);
|
|
};
|
|
|
|
// Add event listener to the map container and document
|
|
const mapContainer = mapContainerRef.current;
|
|
|
|
// Clean up previous listeners to prevent duplicates
|
|
document.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
|
|
if (mapContainer) {
|
|
mapContainer.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
|
|
}
|
|
|
|
// Listen on both the container and document to ensure we catch the event
|
|
document.addEventListener('incident_click', handleIncidentClickEvent as EventListener);
|
|
|
|
if (mapContainer) {
|
|
mapContainer.addEventListener('incident_click', handleIncidentClickEvent as EventListener);
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
|
|
if (mapContainer) {
|
|
mapContainer.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
|
|
}
|
|
};
|
|
}, [filteredCrimes]);
|
|
|
|
// Set up event listener for fly-to map control
|
|
useEffect(() => {
|
|
const handleMapFlyTo = (e: CustomEvent) => {
|
|
if (!e.detail) {
|
|
console.error("Invalid fly-to data:", e.detail);
|
|
return;
|
|
}
|
|
|
|
const { longitude, latitude, zoom, bearing, pitch, duration } = e.detail;
|
|
|
|
// Find the map instance
|
|
const mapInstance = mapContainerRef.current?.querySelector('.mapboxgl-map');
|
|
if (!mapInstance) {
|
|
console.error("Map instance not found");
|
|
return;
|
|
}
|
|
|
|
// Create and dispatch a custom event that MapView component will listen for
|
|
const mapboxEvent = new CustomEvent('mapbox_fly', {
|
|
detail: {
|
|
center: [longitude, latitude],
|
|
zoom: zoom || 15,
|
|
bearing: bearing || 0,
|
|
pitch: pitch || 45,
|
|
duration: duration || 2000
|
|
},
|
|
bubbles: true
|
|
});
|
|
|
|
mapInstance.dispatchEvent(mapboxEvent);
|
|
};
|
|
|
|
// Add event listener
|
|
document.addEventListener('mapbox_fly_to', handleMapFlyTo as EventListener);
|
|
|
|
return () => {
|
|
document.removeEventListener('mapbox_fly_to', handleMapFlyTo as EventListener);
|
|
};
|
|
}, []);
|
|
|
|
// Add event listener for map reset
|
|
useEffect(() => {
|
|
const handleMapReset = (e: CustomEvent) => {
|
|
const { duration } = e.detail || { duration: 1500 };
|
|
|
|
// Find the map instance
|
|
const mapInstance = mapContainerRef.current?.querySelector('.mapboxgl-map');
|
|
if (!mapInstance) {
|
|
console.error("Map instance not found");
|
|
return;
|
|
}
|
|
|
|
// Create and dispatch the reset event that MapView will listen for
|
|
const mapboxEvent = new CustomEvent('mapbox_fly', {
|
|
detail: {
|
|
duration: duration,
|
|
resetCamera: true
|
|
},
|
|
bubbles: true
|
|
});
|
|
|
|
mapInstance.dispatchEvent(mapboxEvent);
|
|
};
|
|
|
|
// Add event listener
|
|
document.addEventListener('mapbox_reset', handleMapReset as EventListener);
|
|
|
|
return () => {
|
|
document.removeEventListener('mapbox_reset', handleMapReset as EventListener);
|
|
};
|
|
}, []);
|
|
|
|
// Update the popup close handler to reset the map view
|
|
const handlePopupClose = () => {
|
|
setSelectedIncident(null);
|
|
|
|
// Dispatch map reset event to reset zoom, pitch, and bearing
|
|
const mapInstance = mapContainerRef.current?.querySelector('.mapboxgl-map');
|
|
|
|
if (mapInstance) {
|
|
const resetEvent = new CustomEvent('mapbox_reset', {
|
|
detail: {
|
|
duration: 1500,
|
|
},
|
|
bubbles: true
|
|
});
|
|
|
|
mapInstance.dispatchEvent(resetEvent);
|
|
}
|
|
}
|
|
|
|
// Handle year-month timeline change
|
|
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
|
|
setSelectedYear(year)
|
|
setSelectedMonth(month)
|
|
setYearProgress(progress)
|
|
}, [])
|
|
|
|
// Handle timeline playing state change
|
|
const handleTimelinePlayingChange = useCallback((playing: boolean) => {
|
|
setisTimelapsePlaying(playing)
|
|
|
|
// When timelapse starts, close any open popups/details
|
|
if (playing) {
|
|
setSelectedIncident(null)
|
|
setSelectedDistrict(null)
|
|
}
|
|
}, [])
|
|
|
|
// Reset filters
|
|
const resetFilters = useCallback(() => {
|
|
setSelectedYear(2024)
|
|
setSelectedMonth("all")
|
|
setSelectedCategory("all")
|
|
}, [])
|
|
|
|
// Determine the title based on filters
|
|
const getMapTitle = () => {
|
|
let title = `${selectedYear}`
|
|
if (selectedMonth !== "all") {
|
|
title += ` - ${getMonthName(Number(selectedMonth))}`
|
|
}
|
|
if (selectedCategory !== "all") {
|
|
title += ` - ${selectedCategory}`
|
|
}
|
|
return title
|
|
}
|
|
|
|
// Handle control changes from the top controls component
|
|
const handleControlChange = (controlId: ITooltips) => {
|
|
setActiveControl(controlId)
|
|
|
|
// Toggle search state when search control is clicked
|
|
if (controlId === "search") {
|
|
setIsSearchActive(prev => !prev)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card className="w-full p-0 border-none shadow-none h-96">
|
|
<CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between">
|
|
<CardTitle>Crime Map {getMapTitle()}</CardTitle>
|
|
<MapSelectors
|
|
availableYears={availableYears || []}
|
|
selectedYear={selectedYear}
|
|
setSelectedYear={setSelectedYear}
|
|
selectedMonth={selectedMonth}
|
|
setSelectedMonth={setSelectedMonth}
|
|
selectedCategory={selectedCategory}
|
|
setSelectedCategory={setSelectedCategory}
|
|
categories={categories}
|
|
isYearsLoading={isYearsLoading}
|
|
isCategoryLoading={isCategoryLoading}
|
|
/>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{isCrimesLoading ? (
|
|
<div className="flex items-center justify-center h-96">
|
|
<Skeleton className="h-full w-full rounded-md" />
|
|
</div>
|
|
) : crimesError ? (
|
|
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
|
<AlertCircle className="h-10 w-10 text-destructive" />
|
|
<p className="text-center">Failed to load crime data. Please try again later.</p>
|
|
<Button onClick={() => window.location.reload()}>Retry</Button>
|
|
</div>
|
|
) : (
|
|
<div className="relative h-[600px]" ref={mapContainerRef}>
|
|
<div className={cn(
|
|
"transition-all duration-300 ease-in-out",
|
|
!sidebarCollapsed && isFullscreen && "ml-[400px]"
|
|
)}>
|
|
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
|
|
{/* District Layer with crime data - don't pass onClick if we want internal popup */}
|
|
<DistrictLayer
|
|
crimes={filteredCrimes || []}
|
|
year={selectedYear.toString()}
|
|
month={selectedMonth.toString()}
|
|
filterCategory={selectedCategory}
|
|
|
|
/>
|
|
|
|
{/* Popup for selected incident */}
|
|
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
|
|
<>
|
|
<CrimePopup
|
|
longitude={selectedIncident.longitude}
|
|
latitude={selectedIncident.latitude}
|
|
onClose={handlePopupClose}
|
|
incident={selectedIncident}
|
|
/>
|
|
|
|
</>
|
|
)}
|
|
|
|
{/* Components that are only visible in fullscreen mode */}
|
|
{isFullscreen && (
|
|
<>
|
|
<div className="absolute flex w-full p-2">
|
|
<Tooltips
|
|
activeControl={activeControl}
|
|
onControlChange={handleControlChange}
|
|
selectedYear={selectedYear}
|
|
setSelectedYear={setSelectedYear}
|
|
selectedMonth={selectedMonth}
|
|
setSelectedMonth={setSelectedMonth}
|
|
selectedCategory={selectedCategory}
|
|
setSelectedCategory={setSelectedCategory}
|
|
availableYears={availableYears || []}
|
|
categories={categories}
|
|
crimes={filteredCrimes}
|
|
/>
|
|
</div>
|
|
|
|
{/* Pass selectedCategory, selectedYear, and selectedMonth to the sidebar */}
|
|
<CrimeSidebar
|
|
crimes={filteredCrimes || []}
|
|
defaultCollapsed={sidebarCollapsed}
|
|
selectedCategory={selectedCategory}
|
|
selectedYear={selectedYear}
|
|
selectedMonth={selectedMonth}
|
|
/>
|
|
<div className="absolute bottom-20 right-0 z-10 p-2">
|
|
<MapLegend position="bottom-right" />
|
|
</div>
|
|
|
|
</>
|
|
)}
|
|
|
|
{isFullscreen && (
|
|
<div className="absolute flex w-full bottom-0">
|
|
<CrimeTimelapse
|
|
startYear={2020}
|
|
endYear={2024}
|
|
autoPlay={false}
|
|
onChange={handleTimelineChange}
|
|
onPlayingChange={handleTimelinePlayingChange}
|
|
/>
|
|
</div>
|
|
)}
|
|
</MapView>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|