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 = [
{ id: "incidents" as ITooltips, icon: <AlertTriangle size={20} />, label: "All Incidents" },
{ 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: "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" },
]

View File

@ -2,7 +2,6 @@
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"
@ -23,6 +22,7 @@ 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"
import Layers from "./layers/layers"
import DistrictLayer, { DistrictFeature } from "./layers/district-layer-old"
// Updated CrimeIncident type to match the structure in crime_incidents
interface ICrimeIncident {
@ -360,22 +360,25 @@ export default function CrimeMap() {
!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 */}
{/* Replace the DistrictLayer with the new Layers component */}
<Layers
crimes={filteredCrimes || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
activeControl={activeControl}
/>
{/* <DistrictLayer
crimes={filteredCrimes || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
activeControl={activeControl}
selectedDistrict={selectedDistrict}
setSelectedDistrict={setSelectedDistrict}
/> */}
<Layers
crimes={filteredCrimes || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
/>
{/* Popup for selected incident */}
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<>

View File

@ -7,13 +7,20 @@ import type { GeoJSON } from "geojson"
import { IClusterLayerProps } from "@/app/_utils/types/map"
import { extractCrimeIncidents } from "@/app/_utils/map"
interface ExtendedClusterLayerProps extends IClusterLayerProps {
clusteringEnabled?: boolean;
showClusters?: boolean;
}
export default function ClusterLayer({
visible = true,
map,
crimes = [],
filterCategory = "all",
focusedDistrictId,
}: IClusterLayerProps) {
clusteringEnabled = false,
showClusters = false,
}: ExtendedClusterLayerProps) {
const handleClusterClick = useCallback(
(e: any) => {
if (!map) return
@ -92,7 +99,7 @@ export default function ClusterLayer({
type: "FeatureCollection",
features: allIncidents as GeoJSON.Feature[],
},
cluster: true,
cluster: clusteringEnabled,
clusterMaxZoom: 14,
clusterRadius: 50,
})
@ -110,7 +117,7 @@ export default function ClusterLayer({
"circle-opacity": 0.75,
},
layout: {
visibility: focusedDistrictId ? "none" : "visible",
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
},
},
firstSymbolId,
@ -127,7 +134,7 @@ export default function ClusterLayer({
"text-field": "{point_count_abbreviated}",
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
"text-size": 12,
visibility: focusedDistrictId ? "none" : "visible",
visibility: showClusters && !focusedDistrictId ? "visible" : "none",
},
paint: {
"text-color": "#ffffff",
@ -147,12 +154,80 @@ export default function ClusterLayer({
map.off("click", "clusters", handleClusterClick)
map.on("click", "clusters", handleClusterClick)
} 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")) {
map.setLayoutProperty("clusters", "visibility", focusedDistrictId ? "none" : "visible")
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
}
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
@ -175,7 +250,7 @@ export default function ClusterLayer({
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
useEffect(() => {
@ -192,5 +267,17 @@ export default function ClusterLayer({
}
}, [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
}

View File

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

View File

@ -1,5 +1,6 @@
"use client"
import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map"
import { createFillColorExpression, processDistrictFeature } from "@/app/_utils/map"
import { IDistrictLayerProps } from "@/app/_utils/types/map"
import { useEffect } from "react"
@ -41,8 +42,9 @@ export default function DistrictFillLineLayer({
// Reset pitch and bearing with animation
map.easeTo({
pitch: 0,
bearing: 0,
zoom: BASE_ZOOM,
pitch: BASE_PITCH,
bearing: BASE_BEARING,
duration: 1500,
easing: (t) => t * (2 - t), // easeOutQuad
})
@ -75,7 +77,7 @@ export default function DistrictFillLineLayer({
// Fly to the new district
map.flyTo({
center: [district.longitude, district.latitude],
zoom: 14.5,
zoom: 12.5,
pitch: 75,
bearing: 0,
duration: 1500,
@ -111,7 +113,7 @@ export default function DistrictFillLineLayer({
// Animate to a pitched view focused on the district
map.flyTo({
center: [district.longitude, district.latitude],
zoom: 14.5,
zoom: 12.5,
pitch: 75,
bearing: 0,
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 DistrictExtrusionLayer from "./district-extrusion-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 { IDistrictFeature } from "@/app/_utils/types/map"
import { createFillColorExpression, processCrimeDataByDistrict } from "@/app/_utils/map"
import DistrictFillLineLayer from "./district-layer"
import UnclusteredPointLayer from "./uncluster-layer"
import FlyToHandler from "../fly-to"
import { toast } from "sonner"
import { ITooltips } from "../controls/top/tooltips"
// District layer props
export interface IDistrictLayerProps {
@ -27,15 +28,25 @@ export interface IDistrictLayerProps {
tilesetId?: string
}
interface LayersProps {
visible?: boolean;
crimes: ICrimes[];
year: string;
month: string;
filterCategory: string | "all";
activeControl: ITooltips;
tilesetId?: string;
}
export default function Layers({
visible = true,
onClick,
crimes,
year,
month,
filterCategory = "all",
crimes = [],
filterCategory,
activeControl,
tilesetId = MAPBOX_TILESET_ID,
}: IDistrictLayerProps) {
}: LayersProps) {
const { current: map } = useMap()
if (!map) {
@ -76,17 +87,6 @@ export default function Layers({
};
}, [mapboxMap]);
// Handle district selection
const handleDistrictClick = (district: IDistrictFeature) => {
selectedDistrictRef.current = district
if (onClick) {
onClick(district)
} else {
setSelectedDistrict(district)
}
}
// Handle popup close
const handleCloseDistrictPopup = () => {
console.log("Closing district popup")
@ -204,22 +204,32 @@ export default function Layers({
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 (
<>
<DistrictFillLineLayer
visible={visible}
map={mapboxMap}
onClick={handleDistrictClick}
{/* Standard District Layer with incident points */}
<DistrictLayer
crimes={crimes}
year={year}
month={month}
filterCategory={filterCategory}
crimes={crimes}
tilesetId={tilesetId}
focusedDistrictId={focusedDistrictId}
setFocusedDistrictId={setFocusedDistrictId}
crimeDataByDistrict={crimeDataByDistrict}
visible={showDistrictLayer}
/>
{/* Heatmap Layer */}
<HeatmapLayer
crimes={crimes}
year={year}
month={month}
filterCategory={filterCategory}
visible={showHeatmapLayer}
/>
{/* District base layer is always needed */}
<DistrictExtrusionLayer
visible={visible}
map={mapboxMap}
@ -228,16 +238,20 @@ export default function Layers({
crimeDataByDistrict={crimeDataByDistrict}
/>
{/* Cluster Layer - only enable clustering and make visible when the clusters control is active */}
<ClusterLayer
visible={visible}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}
focusedDistrictId={focusedDistrictId}
clusteringEnabled={showClustersLayer}
showClusters={showClustersLayer}
/>
{/* Unclustered Points Layer - hide when in cluster mode */}
<UnclusteredPointLayer
visible={visible}
visible={visible && !showClustersLayer && showDistrictLayer}
map={mapboxMap}
crimes={crimes}
filterCategory={filterCategory}

View File

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