feat: Enhance heatmap and timeline layers with new data handling and filtering options

This commit is contained in:
vergiLgood1 2025-05-05 11:11:11 +07:00
parent a19e8ec32d
commit fa7651619b
4 changed files with 136 additions and 96 deletions

View File

@ -27,7 +27,6 @@ import Layers from "./layers/layers"
import DistrictLayer, { DistrictFeature } from "./layers/district-layer-old"
import { useGetUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries"
// Updated CrimeIncident type to match the structure in crime_incidents
interface ICrimeIncident {
id: string
district?: string
@ -42,7 +41,6 @@ interface ICrimeIncident {
}
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)
@ -55,28 +53,25 @@ export default function CrimeMap() {
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
const [isSearchActive, setIsSearchActive] = useState(false)
const [showUnitsLayer, setShowUnitsLayer] = useState(false)
const [useAllYears, setUseAllYears] = useState<boolean>(false)
const [useAllMonths, setUseAllMonths] = useState<boolean>(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,
@ -85,22 +80,40 @@ export default function CrimeMap() {
const { data: fetchedUnits, isLoading } = useGetUnitsQuery()
// Filter crimes based on selected year and month
useEffect(() => {
if (activeControl === "heatmap" || activeControl === "timeline") {
setUseAllYears(true);
setUseAllMonths(true);
} else {
setUseAllYears(false);
setUseAllMonths(false);
}
}, [activeControl]);
const filteredByYearAndMonth = useMemo(() => {
if (!crimes) return []
if (!crimes) return [];
if (useAllYears) {
if (useAllMonths) {
return crimes;
} else {
return crimes.filter((crime) => {
return selectedMonth === "all" ? true : crime.month === selectedMonth;
});
}
}
return crimes.filter((crime) => {
const yearMatch = crime.year === selectedYear
const yearMatch = crime.year === selectedYear;
if (selectedMonth === "all") {
return yearMatch
if (selectedMonth === "all" || useAllMonths) {
return yearMatch;
} else {
return yearMatch && crime.month === selectedMonth
return yearMatch && crime.month === selectedMonth;
}
})
}, [crimes, selectedYear, selectedMonth])
});
}, [crimes, selectedYear, selectedMonth, useAllYears, useAllMonths]);
// Filter incidents based on selected category
const filteredCrimes = useMemo(() => {
if (!filteredByYearAndMonth) return []
if (selectedCategory === "all") return filteredByYearAndMonth
@ -118,7 +131,6 @@ export default function CrimeMap() {
})
}, [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);
@ -127,14 +139,11 @@ export default function CrimeMap() {
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,
@ -156,29 +165,23 @@ export default function CrimeMap() {
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) {
@ -193,7 +196,6 @@ export default function CrimeMap() {
};
}, [filteredCrimes]);
// Set up event listener for fly-to map control
useEffect(() => {
const handleMapFlyTo = (e: CustomEvent) => {
if (!e.detail) {
@ -203,14 +205,12 @@ export default function CrimeMap() {
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],
@ -225,7 +225,6 @@ export default function CrimeMap() {
mapInstance.dispatchEvent(mapboxEvent);
};
// Add event listener
document.addEventListener('mapbox_fly_to', handleMapFlyTo as EventListener);
return () => {
@ -233,19 +232,16 @@ export default function CrimeMap() {
};
}, []);
// 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,
@ -257,7 +253,6 @@ export default function CrimeMap() {
mapInstance.dispatchEvent(mapboxEvent);
};
// Add event listener
document.addEventListener('mapbox_reset', handleMapReset as EventListener);
return () => {
@ -265,11 +260,9 @@ export default function CrimeMap() {
};
}, []);
// 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) {
@ -284,57 +277,61 @@ export default function CrimeMap() {
}
}
// 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 (useAllYears) {
return `All Years Data ${selectedCategory !== "all" ? `- ${selectedCategory}` : ''}`;
}
let title = `${selectedYear}`;
if (selectedMonth !== "all" && !useAllMonths) {
title += ` - ${getMonthName(Number(selectedMonth))}`;
}
if (selectedCategory !== "all") {
title += ` - ${selectedCategory}`
title += ` - ${selectedCategory}`;
}
return title
return title;
}
// Handle control changes from the top controls component
const handleControlChange = (controlId: ITooltips) => {
setActiveControl(controlId)
setActiveControl(controlId);
// Toggle search state when search control is clicked
if (controlId === "search") {
setIsSearchActive(prev => !prev)
setIsSearchActive(prev => !prev);
}
// Toggle units layer visibility when units control is clicked
if (controlId === "units") {
setShowUnitsLayer(true)
setShowUnitsLayer(true);
} else if (showUnitsLayer) {
setShowUnitsLayer(false)
setShowUnitsLayer(false);
}
if (controlId === "heatmap" || controlId === "timeline") {
setUseAllYears(true);
setUseAllMonths(true);
} else {
setUseAllYears(false);
setUseAllMonths(false);
}
}
@ -375,7 +372,6 @@ export default function CrimeMap() {
!sidebarCollapsed && isFullscreen && "ml-[400px]"
)}>
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
{/* Replace the DistrictLayer with the new Layers component */}
<Layers
crimes={filteredCrimes || []}
units={fetchedUnits || []}
@ -383,9 +379,9 @@ export default function CrimeMap() {
month={selectedMonth.toString()}
filterCategory={selectedCategory}
activeControl={activeControl}
useAllData={useAllYears}
/>
{/* Popup for selected incident */}
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<>
<CrimePopup
@ -397,7 +393,6 @@ export default function CrimeMap() {
</>
)}
{/* Components that are only visible in fullscreen mode */}
{isFullscreen && (
<>
<div className="absolute flex w-full p-2">
@ -416,7 +411,6 @@ export default function CrimeMap() {
/>
</div>
{/* Pass selectedCategory, selectedYear, and selectedMonth to the sidebar */}
<CrimeSidebar
crimes={filteredCrimes || []}
defaultCollapsed={sidebarCollapsed}

View File

@ -8,20 +8,40 @@ interface HeatmapLayerProps {
month: string;
filterCategory: string | "all";
visible?: boolean;
useAllData?: boolean; // Add prop to indicate if we should use all data
}
export default function HeatmapLayer({ crimes, visible = true }: HeatmapLayerProps) {
export default function HeatmapLayer({
crimes,
visible = true,
useAllData = false,
filterCategory
}: HeatmapLayerProps) {
// Convert crime data to GeoJSON format for the heatmap
const heatmapData = useMemo(() => {
const features = crimes.flatMap(crime =>
crime.crime_incidents
.filter(incident => incident.locations?.latitude && incident.locations?.longitude)
.filter(incident => {
// Enhanced filtering logic
if (!incident.locations?.latitude || !incident.locations?.longitude) {
return false;
}
// Filter by category if specified
if (filterCategory !== "all" &&
incident.crime_categories?.name !== filterCategory) {
return false;
}
return true;
})
.map(incident => ({
type: "Feature" as const,
properties: {
id: incident.id,
category: incident.crime_categories?.name || "Unknown",
intensity: 1, // Base intensity value
timestamp: incident.timestamp ? new Date(incident.timestamp).getTime() : null,
},
geometry: {
type: "Point" as const,
@ -34,7 +54,7 @@ export default function HeatmapLayer({ crimes, visible = true }: HeatmapLayerPro
type: "FeatureCollection" as const,
features,
};
}, [crimes]);
}, [crimes, filterCategory]);
if (!visible) return null;
@ -44,36 +64,36 @@ export default function HeatmapLayer({ crimes, visible = true }: HeatmapLayerPro
id="crime-heatmap"
type="heatmap"
paint={{
// Heatmap radius
// Enhanced heatmap configuration
'heatmap-radius': [
'interpolate',
['linear'],
['zoom'],
8, 10, // At zoom level 8, radius will be 10px
13, 25 // At zoom level 13, radius will be 25px
8, 12, // At zoom level 8, radius will be 12px
13, 30 // At zoom level 13, radius will be 30px
],
// Heatmap intensity
'heatmap-intensity': [
'interpolate',
['linear'],
['zoom'],
8, 0.5, // Less intense at zoom level 8
13, 1.5 // More intense at zoom level 13
8, 0.7, // More intense at zoom level 8
13, 2.0 // Even more intense at zoom level 13
],
// Color gradient from low to high density
// Improved color gradient for better visualization
'heatmap-color': [
'interpolate',
['linear'],
['heatmap-density'],
0, 'rgba(33,102,172,0)',
0.2, 'rgb(103,169,207)',
0.4, 'rgb(209,229,240)',
0.6, 'rgb(253,219,199)',
0.8, 'rgb(239,138,98)',
0.1, 'rgb(36,104,180)',
0.3, 'rgb(103,169,207)',
0.5, 'rgb(209,229,240)',
0.7, 'rgb(253,219,199)',
0.9, 'rgb(239,138,98)',
1, 'rgb(178,24,43)'
],
// Heatmap opacity
'heatmap-opacity': 0.8,
// Higher opacity when showing all data for better visibility
'heatmap-opacity': useAllData ? 0.9 : 0.8,
// Heatmap weight based on properties
'heatmap-weight': [
'interpolate',

View File

@ -42,6 +42,7 @@ interface LayersProps {
filterCategory: string | "all";
activeControl: ITooltips;
tilesetId?: string;
useAllData?: boolean; // New prop to indicate if we're showing all data
}
export default function Layers({
@ -53,6 +54,7 @@ export default function Layers({
filterCategory,
activeControl,
tilesetId = MAPBOX_TILESET_ID,
useAllData = false,
}: LayersProps) {
const { current: map } = useMap()
@ -226,9 +228,12 @@ export default function Layers({
// District fill should only be visible for incidents and clusters
const showDistrictFill = activeControl === "incidents" || activeControl === "clusters";
// Only show incident markers for incidents and clusters views - exclude timeline mode
const showIncidentMarkers = (activeControl === "incidents" || activeControl === "clusters")
return (
<>
{/* Ensure we pass the proper defined handler */}
{/* Standard District Layer with incident points */}
<DistrictFillLineLayer
visible={true}
map={mapboxMap}
@ -244,17 +249,6 @@ export default function Layers({
activeControl={activeControl}
/>
{/* Standard District Layer with incident points */}
{/* <DistrictLayer
crimes={crimes}
year={year}
month={month}
filterCategory={filterCategory}
visible={true} // Keep the layer but control fill opacity
showFill={showDistrictFill}
activeControl={activeControl}
/> */}
{/* Heatmap Layer */}
<HeatmapLayer
crimes={crimes}
@ -262,9 +256,10 @@ export default function Layers({
month={month}
filterCategory={filterCategory}
visible={showHeatmapLayer}
useAllData={useAllData}
/>
{/* Timeline Layer - show average incident time per district */}
{/* Timeline Layer - make sure this is the only visible layer in timeline mode */}
<TimelineLayer
crimes={crimes}
year={year}
@ -272,9 +267,10 @@ export default function Layers({
filterCategory={filterCategory}
visible={showTimelineLayer}
map={mapboxMap}
useAllData={useAllData}
/>
{/* Units Layer - show police stations and connection lines */}
{/* Units Layer */}
<UnitsLayer
crimes={crimes}
units={units}
@ -283,7 +279,7 @@ export default function Layers({
map={mapboxMap}
/>
{/* District base layer is always needed */}
{/* District base layer */}
<DistrictExtrusionLayer
visible={visible}
map={mapboxMap}
@ -292,9 +288,9 @@ export default function Layers({
crimeDataByDistrict={crimeDataByDistrict}
/>
{/* Cluster Layer - only enable clustering and make visible when the clusters control is active */}
{/* Cluster Layer - only enable when the clusters control is active and NOT in timeline mode */}
<ClusterLayer
visible={visible}
visible={visible && !showTimelineLayer}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}
@ -303,9 +299,9 @@ export default function Layers({
showClusters={showClustersLayer}
/>
{/* Unclustered Points Layer - hide when in cluster mode or units mode */}
{/* Unclustered Points Layer - explicitly hide in timeline mode */}
<UnclusteredPointLayer
visible={visible && !showClustersLayer && showDistrictLayer && !showUnitsLayer}
visible={visible && showIncidentMarkers && !focusedDistrictId && !showTimelineLayer}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}

View File

@ -7,7 +7,6 @@ import mapboxgl from 'mapbox-gl'
import { format } from 'date-fns'
import { calculateAverageTimeOfDay } from '@/app/_utils/time'
interface TimelineLayerProps {
crimes: ICrimes[]
year: string
@ -15,6 +14,7 @@ interface TimelineLayerProps {
filterCategory: string | "all"
visible?: boolean
map?: mapboxgl.Map | null
useAllData?: boolean // New prop to use all data
}
export default function TimelineLayer({
@ -23,7 +23,8 @@ export default function TimelineLayer({
month,
filterCategory,
visible = false,
map
map,
useAllData = false // Default to false
}: TimelineLayerProps) {
// State to hold the currently selected district for popup display
const [selectedDistrict, setSelectedDistrict] = useState<string | null>(null)
@ -61,11 +62,13 @@ export default function TimelineLayer({
})
}
// Add valid incidents to the district group
// Filter incidents appropriately before adding
crime.crime_incidents.forEach(incident => {
// Skip invalid incidents
if (!incident.timestamp) return
if (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) return
// Add to appropriate district group
const group = districtGroups.get(crime.district_id)
if (group) {
group.incidents.push({
@ -105,8 +108,11 @@ export default function TimelineLayer({
}
})
// Add title to indicate all years data
const title = useAllData ? "All Years Data" : `Year: ${year}${month !== "all" ? `, Month: ${month}` : ""}`;
return result
}, [crimes, filterCategory])
}, [crimes, filterCategory, useAllData, year, month])
// Convert processed data to GeoJSON for display
const timelineGeoJSON = useMemo(() => {
@ -141,6 +147,30 @@ export default function TimelineLayer({
}
}
// Add an effect to hide all other incident markers and clusters when timeline is active
useEffect(() => {
if (!map || !visible) return;
// Hide incident markers when timeline mode is activated
if (map.getLayer("unclustered-point")) {
map.setLayoutProperty("unclustered-point", "visibility", "none");
}
// Hide clusters when timeline mode is activated
if (map.getLayer("clusters")) {
map.setLayoutProperty("clusters", "visibility", "none");
}
if (map.getLayer("cluster-count")) {
map.setLayoutProperty("cluster-count", "visibility", "none");
}
return () => {
// This cleanup won't restore visibility since that's handled by the parent component
// based on the activeControl value
};
}, [map, visible]);
// Event handlers
useEffect(() => {
if (!map || !visible) return