From fa7651619ba9c0ac1e2efa65ff65b7470ea974fd Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Mon, 5 May 2025 11:11:11 +0700 Subject: [PATCH] feat: Enhance heatmap and timeline layers with new data handling and filtering options --- .../app/_components/map/crime-map.tsx | 108 +++++++++--------- .../_components/map/layers/heatmap-layer.tsx | 52 ++++++--- .../app/_components/map/layers/layers.tsx | 34 +++--- .../_components/map/layers/timeline-layer.tsx | 38 +++++- 4 files changed, 136 insertions(+), 96 deletions(-) diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index 54e07d2..650f1ad 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -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(null) const [selectedIncident, setSelectedIncident] = useState(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(false) + const [useAllMonths, setUseAllMonths] = useState(false) const mapContainerRef = useRef(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]" )}> - {/* Replace the DistrictLayer with the new Layers component */} - {/* Popup for selected incident */} {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( <> )} - {/* Components that are only visible in fullscreen mode */} {isFullscreen && ( <>
@@ -416,7 +411,6 @@ export default function CrimeMap() { />
- {/* Pass selectedCategory, selectedYear, and selectedMonth to the sidebar */} { 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', diff --git a/sigap-website/app/_components/map/layers/layers.tsx b/sigap-website/app/_components/map/layers/layers.tsx index 8639dd6..5104d0b 100644 --- a/sigap-website/app/_components/map/layers/layers.tsx +++ b/sigap-website/app/_components/map/layers/layers.tsx @@ -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 */} - {/* Standard District Layer with incident points */} - {/* */} - {/* Heatmap Layer */} - {/* Timeline Layer - show average incident time per district */} + {/* Timeline Layer - make sure this is the only visible layer in timeline mode */} - {/* Units Layer - show police stations and connection lines */} + {/* Units Layer */} - {/* District base layer is always needed */} + {/* District base layer */} - {/* 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 */} - {/* Unclustered Points Layer - hide when in cluster mode or units mode */} + {/* Unclustered Points Layer - explicitly hide in timeline mode */} (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