feat: add heatmap layer and enhance clustering functionality in map layers
This commit is contained in:
parent
c6115adf88
commit
b0db61a9ff
|
@ -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" },
|
||||
]
|
||||
|
||||
|
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue