feat: add heatmap layer and enhance clustering functionality in map layers

This commit is contained in:
vergiLgood1 2025-05-05 06:47:59 +07:00
parent c6115adf88
commit b0db61a9ff
8 changed files with 250 additions and 53 deletions

View File

@ -11,9 +11,9 @@ import { IconBubble, IconChartBubble } from "@tabler/icons-react"
const crimeTooltips = [ const crimeTooltips = [
{ id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" }, { id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" },
{ id: "heatmap" as ITooltips, icon: <Map size={20} />, label: "Crime Heatmap" }, { id: "heatmap" as ITooltips, icon: <Map size={20} />, label: "Crime Heatmap" },
{ id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clustered Incidents" },
{ id: "units" as ITooltips, icon: <BarChart2 size={20} />, label: "Units" }, { id: "units" as ITooltips, icon: <BarChart2 size={20} />, label: "Units" },
{ id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" }, { id: "patrol" as ITooltips, icon: <Car size={20} />, label: "Patrol Areas" },
{ id: "clusters" as ITooltips, icon: <IconChartBubble size={20} />, label: "Clusters" },
{ id: "timeline" as ITooltips, icon: <Clock size={20} />, label: "Time Analysis" }, { id: "timeline" as ITooltips, icon: <Clock size={20} />, label: "Time Analysis" },
] ]

View File

@ -2,7 +2,6 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
import { Skeleton } from "@/app/_components/ui/skeleton" import { Skeleton } from "@/app/_components/ui/skeleton"
import DistrictLayer, { type DistrictFeature } from "./layers/district-layer-old"
import MapView from "./map" import MapView from "./map"
import { Button } from "@/app/_components/ui/button" import { Button } from "@/app/_components/ui/button"
import { AlertCircle } from "lucide-react" import { AlertCircle } from "lucide-react"
@ -23,6 +22,7 @@ import CrimeSidebar from "./controls/left/sidebar/map-sidebar"
import Tooltips from "./controls/top/tooltips" import Tooltips from "./controls/top/tooltips"
import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map" import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map"
import Layers from "./layers/layers" import Layers from "./layers/layers"
import DistrictLayer, { DistrictFeature } from "./layers/district-layer-old"
// Updated CrimeIncident type to match the structure in crime_incidents // Updated CrimeIncident type to match the structure in crime_incidents
interface ICrimeIncident { interface ICrimeIncident {
@ -360,22 +360,25 @@ export default function CrimeMap() {
!sidebarCollapsed && isFullscreen && "ml-[400px]" !sidebarCollapsed && isFullscreen && "ml-[400px]"
)}> )}>
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md"> <MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
{/* District Layer with crime data - don't pass onClick if we want internal popup */} {/* Replace the DistrictLayer with the new Layers component */}
<Layers
crimes={filteredCrimes || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
activeControl={activeControl}
/>
{/* <DistrictLayer {/* <DistrictLayer
crimes={filteredCrimes || []} crimes={filteredCrimes || []}
year={selectedYear.toString()} year={selectedYear.toString()}
month={selectedMonth.toString()} month={selectedMonth.toString()}
filterCategory={selectedCategory} filterCategory={selectedCategory}
activeControl={activeControl}
selectedDistrict={selectedDistrict}
setSelectedDistrict={setSelectedDistrict}
/> */} /> */}
<Layers
crimes={filteredCrimes || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
/>
{/* Popup for selected incident */} {/* Popup for selected incident */}
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<> <>

View File

@ -7,13 +7,20 @@ import type { GeoJSON } from "geojson"
import { IClusterLayerProps } from "@/app/_utils/types/map" import { IClusterLayerProps } from "@/app/_utils/types/map"
import { extractCrimeIncidents } from "@/app/_utils/map" import { extractCrimeIncidents } from "@/app/_utils/map"
interface ExtendedClusterLayerProps extends IClusterLayerProps {
clusteringEnabled?: boolean;
showClusters?: boolean;
}
export default function ClusterLayer({ export default function ClusterLayer({
visible = true, visible = true,
map, map,
crimes = [], crimes = [],
filterCategory = "all", filterCategory = "all",
focusedDistrictId, focusedDistrictId,
}: IClusterLayerProps) { clusteringEnabled = false,
showClusters = false,
}: ExtendedClusterLayerProps) {
const handleClusterClick = useCallback( const handleClusterClick = useCallback(
(e: any) => { (e: any) => {
if (!map) return if (!map) return
@ -92,7 +99,7 @@ export default function ClusterLayer({
type: "FeatureCollection", type: "FeatureCollection",
features: allIncidents as GeoJSON.Feature[], features: allIncidents as GeoJSON.Feature[],
}, },
cluster: true, cluster: clusteringEnabled,
clusterMaxZoom: 14, clusterMaxZoom: 14,
clusterRadius: 50, clusterRadius: 50,
}) })
@ -110,7 +117,7 @@ export default function ClusterLayer({
"circle-opacity": 0.75, "circle-opacity": 0.75,
}, },
layout: { layout: {
visibility: focusedDistrictId ? "none" : "visible", visibility: showClusters && !focusedDistrictId ? "visible" : "none",
}, },
}, },
firstSymbolId, firstSymbolId,
@ -127,7 +134,7 @@ export default function ClusterLayer({
"text-field": "{point_count_abbreviated}", "text-field": "{point_count_abbreviated}",
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"], "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
"text-size": 12, "text-size": 12,
visibility: focusedDistrictId ? "none" : "visible", visibility: showClusters && !focusedDistrictId ? "visible" : "none",
}, },
paint: { paint: {
"text-color": "#ffffff", "text-color": "#ffffff",
@ -147,12 +154,80 @@ export default function ClusterLayer({
map.off("click", "clusters", handleClusterClick) map.off("click", "clusters", handleClusterClick)
map.on("click", "clusters", handleClusterClick) map.on("click", "clusters", handleClusterClick)
} else { } else {
// Update visibility based on focused district // Update source clustering option
try {
// We need to recreate the source if we're changing the clustering option
const currentSource = map.getSource("crime-incidents") as mapboxgl.GeoJSONSource;
const data = (currentSource as any)._data; // Get current data
// If clustering state has changed, recreate the source
const existingClusterState = (currentSource as any).options?.cluster;
if (existingClusterState !== clusteringEnabled) {
// Remove existing layers that use this source
if (map.getLayer("clusters")) map.removeLayer("clusters");
if (map.getLayer("cluster-count")) map.removeLayer("cluster-count");
if (map.getLayer("unclustered-point")) map.removeLayer("unclustered-point");
// Remove and recreate source with new clustering setting
map.removeSource("crime-incidents");
map.addSource("crime-incidents", {
type: "geojson",
data: data,
cluster: clusteringEnabled,
clusterMaxZoom: 14,
clusterRadius: 50,
});
// Re-add the layers
if (!map.getLayer("clusters")) {
map.addLayer(
{
id: "clusters",
type: "circle",
source: "crime-incidents",
filter: ["has", "point_count"],
paint: {
"circle-color": ["step", ["get", "point_count"], "#51bbd6", 5, "#f1f075", 15, "#f28cb1"],
"circle-radius": ["step", ["get", "point_count"], 20, 5, 30, 15, 40],
"circle-opacity": 0.75,
},
layout: {
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
},
},
firstSymbolId,
);
}
if (!map.getLayer("cluster-count")) {
map.addLayer({
id: "cluster-count",
type: "symbol",
source: "crime-incidents",
filter: ["has", "point_count"],
layout: {
"text-field": "{point_count_abbreviated}",
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
"text-size": 12,
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
},
paint: {
"text-color": "#ffffff",
},
});
}
}
} catch (error) {
console.error("Error updating cluster source:", error);
}
// Update visibility based on focused district and showClusters flag
if (map.getLayer("clusters")) { if (map.getLayer("clusters")) {
map.setLayoutProperty("clusters", "visibility", focusedDistrictId ? "none" : "visible") map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
} }
if (map.getLayer("cluster-count")) { if (map.getLayer("cluster-count")) {
map.setLayoutProperty("cluster-count", "visibility", focusedDistrictId ? "none" : "visible") map.setLayoutProperty("cluster-count", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
} }
// Update the cluster click handler // Update the cluster click handler
@ -175,7 +250,7 @@ export default function ClusterLayer({
map.off("click", "clusters", handleClusterClick) map.off("click", "clusters", handleClusterClick)
} }
} }
}, [map, visible, crimes, filterCategory, focusedDistrictId, handleClusterClick]) }, [map, visible, crimes, filterCategory, focusedDistrictId, handleClusterClick, clusteringEnabled, showClusters])
// Update crime incidents data when filters change // Update crime incidents data when filters change
useEffect(() => { useEffect(() => {
@ -192,5 +267,17 @@ export default function ClusterLayer({
} }
}, [map, crimes, filterCategory]) }, [map, crimes, filterCategory])
// Update visibility when showClusters changes
useEffect(() => {
if (!map || !map.getLayer("clusters") || !map.getLayer("cluster-count")) return;
try {
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none");
map.setLayoutProperty("cluster-count", "visibility", showClusters && !focusedDistrictId ? "visible" : "none");
} catch (error) {
console.error("Error updating cluster visibility:", error);
}
}, [map, showClusters, focusedDistrictId]);
return null return null
} }

View File

@ -47,7 +47,7 @@ export interface DistrictFeature {
// District layer props // District layer props
export interface DistrictLayerProps { export interface DistrictLayerProps {
visible?: boolean visible?: boolean // New prop to control visibility
onClick?: (feature: DistrictFeature) => void onClick?: (feature: DistrictFeature) => void
year: string year: string
month: string month: string

View File

@ -1,5 +1,6 @@
"use client" "use client"
import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map"
import { createFillColorExpression, processDistrictFeature } from "@/app/_utils/map" import { createFillColorExpression, processDistrictFeature } from "@/app/_utils/map"
import { IDistrictLayerProps } from "@/app/_utils/types/map" import { IDistrictLayerProps } from "@/app/_utils/types/map"
import { useEffect } from "react" import { useEffect } from "react"
@ -41,8 +42,9 @@ export default function DistrictFillLineLayer({
// Reset pitch and bearing with animation // Reset pitch and bearing with animation
map.easeTo({ map.easeTo({
pitch: 0, zoom: BASE_ZOOM,
bearing: 0, pitch: BASE_PITCH,
bearing: BASE_BEARING,
duration: 1500, duration: 1500,
easing: (t) => t * (2 - t), // easeOutQuad easing: (t) => t * (2 - t), // easeOutQuad
}) })
@ -75,7 +77,7 @@ export default function DistrictFillLineLayer({
// Fly to the new district // Fly to the new district
map.flyTo({ map.flyTo({
center: [district.longitude, district.latitude], center: [district.longitude, district.latitude],
zoom: 14.5, zoom: 12.5,
pitch: 75, pitch: 75,
bearing: 0, bearing: 0,
duration: 1500, duration: 1500,
@ -111,7 +113,7 @@ export default function DistrictFillLineLayer({
// Animate to a pitched view focused on the district // Animate to a pitched view focused on the district
map.flyTo({ map.flyTo({
center: [district.longitude, district.latitude], center: [district.longitude, district.latitude],
zoom: 14.5, zoom: 12.5,
pitch: 75, pitch: 75,
bearing: 0, bearing: 0,
duration: 1500, duration: 1500,

View File

@ -0,0 +1,89 @@
import { Layer, Source } from "react-map-gl/mapbox";
import { useMemo } from "react";
import { ICrimes } from "@/app/_utils/types/crimes";
interface HeatmapLayerProps {
crimes: ICrimes[];
year: string;
month: string;
filterCategory: string | "all";
visible?: boolean;
}
export default function HeatmapLayer({ crimes, visible = true }: 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)
.map(incident => ({
type: "Feature" as const,
properties: {
id: incident.id,
category: incident.crime_categories?.name || "Unknown",
intensity: 1, // Base intensity value
},
geometry: {
type: "Point" as const,
coordinates: [incident.locations!.longitude, incident.locations!.latitude],
},
}))
);
return {
type: "FeatureCollection" as const,
features,
};
}, [crimes]);
if (!visible) return null;
return (
<Source id="crime-heatmap-data" type="geojson" data={heatmapData}>
<Layer
id="crime-heatmap"
type="heatmap"
paint={{
// Heatmap radius
'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
],
// 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
],
// Color gradient from low to high density
'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)',
1, 'rgb(178,24,43)'
],
// Heatmap opacity
'heatmap-opacity': 0.8,
// Heatmap weight based on properties
'heatmap-weight': [
'interpolate',
['linear'],
['get', 'intensity'],
0, 0.5,
5, 2
],
}}
/>
</Source>
);
}

View File

@ -6,15 +6,16 @@ import { BASE_BEARING, BASE_PITCH, BASE_ZOOM, MAPBOX_TILESET_ID } from "@/app/_u
import DistrictPopup from "../pop-up/district-popup" import DistrictPopup from "../pop-up/district-popup"
import DistrictExtrusionLayer from "./district-extrusion-layer" import DistrictExtrusionLayer from "./district-extrusion-layer"
import ClusterLayer from "./cluster-layer" import ClusterLayer from "./cluster-layer"
import HeatmapLayer from "./heatmap-layer"
import DistrictLayer from "./district-layer-old"
import type { ICrimes } from "@/app/_utils/types/crimes" import type { ICrimes } from "@/app/_utils/types/crimes"
import { IDistrictFeature } from "@/app/_utils/types/map" import { IDistrictFeature } from "@/app/_utils/types/map"
import { createFillColorExpression, processCrimeDataByDistrict } from "@/app/_utils/map" import { createFillColorExpression, processCrimeDataByDistrict } from "@/app/_utils/map"
import DistrictFillLineLayer from "./district-layer"
import UnclusteredPointLayer from "./uncluster-layer" import UnclusteredPointLayer from "./uncluster-layer"
import FlyToHandler from "../fly-to" import FlyToHandler from "../fly-to"
import { toast } from "sonner" import { toast } from "sonner"
import { ITooltips } from "../controls/top/tooltips"
// District layer props // District layer props
export interface IDistrictLayerProps { export interface IDistrictLayerProps {
@ -27,15 +28,25 @@ export interface IDistrictLayerProps {
tilesetId?: string tilesetId?: string
} }
interface LayersProps {
visible?: boolean;
crimes: ICrimes[];
year: string;
month: string;
filterCategory: string | "all";
activeControl: ITooltips;
tilesetId?: string;
}
export default function Layers({ export default function Layers({
visible = true, visible = true,
onClick, crimes,
year, year,
month, month,
filterCategory = "all", filterCategory,
crimes = [], activeControl,
tilesetId = MAPBOX_TILESET_ID, tilesetId = MAPBOX_TILESET_ID,
}: IDistrictLayerProps) { }: LayersProps) {
const { current: map } = useMap() const { current: map } = useMap()
if (!map) { if (!map) {
@ -76,17 +87,6 @@ export default function Layers({
}; };
}, [mapboxMap]); }, [mapboxMap]);
// Handle district selection
const handleDistrictClick = (district: IDistrictFeature) => {
selectedDistrictRef.current = district
if (onClick) {
onClick(district)
} else {
setSelectedDistrict(district)
}
}
// Handle popup close // Handle popup close
const handleCloseDistrictPopup = () => { const handleCloseDistrictPopup = () => {
console.log("Closing district popup") console.log("Closing district popup")
@ -204,22 +204,32 @@ export default function Layers({
if (!visible) return null if (!visible) return null
// Determine which layers should be visible based on the active control
const showDistrictLayer = activeControl === "incidents";
const showHeatmapLayer = activeControl === "heatmap";
const showClustersLayer = activeControl === "clusters";
return ( return (
<> <>
<DistrictFillLineLayer {/* Standard District Layer with incident points */}
visible={visible} <DistrictLayer
map={mapboxMap} crimes={crimes}
onClick={handleDistrictClick}
year={year} year={year}
month={month} month={month}
filterCategory={filterCategory} filterCategory={filterCategory}
crimes={crimes} visible={showDistrictLayer}
tilesetId={tilesetId}
focusedDistrictId={focusedDistrictId}
setFocusedDistrictId={setFocusedDistrictId}
crimeDataByDistrict={crimeDataByDistrict}
/> />
{/* Heatmap Layer */}
<HeatmapLayer
crimes={crimes}
year={year}
month={month}
filterCategory={filterCategory}
visible={showHeatmapLayer}
/>
{/* District base layer is always needed */}
<DistrictExtrusionLayer <DistrictExtrusionLayer
visible={visible} visible={visible}
map={mapboxMap} map={mapboxMap}
@ -228,16 +238,20 @@ export default function Layers({
crimeDataByDistrict={crimeDataByDistrict} crimeDataByDistrict={crimeDataByDistrict}
/> />
{/* Cluster Layer - only enable clustering and make visible when the clusters control is active */}
<ClusterLayer <ClusterLayer
visible={visible} visible={visible}
map={mapboxMap} map={mapboxMap}
crimes={crimes} crimes={crimes}
filterCategory={filterCategory} filterCategory={filterCategory}
focusedDistrictId={focusedDistrictId} focusedDistrictId={focusedDistrictId}
clusteringEnabled={showClustersLayer}
showClusters={showClustersLayer}
/> />
{/* Unclustered Points Layer - hide when in cluster mode */}
<UnclusteredPointLayer <UnclusteredPointLayer
visible={visible} visible={visible && !showClustersLayer && showDistrictLayer}
map={mapboxMap} map={mapboxMap}
crimes={crimes} crimes={crimes}
filterCategory={filterCategory} filterCategory={filterCategory}

View File

@ -90,9 +90,11 @@ export interface IExtrusionLayerProps extends IBaseLayerProps {
// Cluster layer props // Cluster layer props
export interface IClusterLayerProps extends IBaseLayerProps { export interface IClusterLayerProps extends IBaseLayerProps {
crimes: ICrimes[]; crimes?: ICrimes[];
filterCategory: string | 'all'; filterCategory?: string | 'all';
focusedDistrictId: string | null; focusedDistrictId?: string | null;
clusteringEnabled?: boolean;
showClusters?: boolean;
} }
// Unclustered point layer props // Unclustered point layer props