338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef, useCallback } from "react"
|
|
import { useMap } from "react-map-gl/mapbox"
|
|
import type { ICrimes } from "@/app/_utils/types/crimes"
|
|
|
|
export interface CrimeClusterLayerProps {
|
|
visible?: boolean
|
|
crimes: ICrimes[]
|
|
filterCategory: string | "all"
|
|
isTimelapsePlaying?: boolean
|
|
beforeId?: string
|
|
}
|
|
|
|
export default function CrimeClusterLayer({
|
|
visible = true,
|
|
crimes = [],
|
|
filterCategory = "all",
|
|
isTimelapsePlaying = false,
|
|
beforeId,
|
|
}: CrimeClusterLayerProps) {
|
|
const { current: map } = useMap()
|
|
const layersAdded = useRef(false)
|
|
|
|
const handleClusterClick = useCallback(
|
|
(e: any) => {
|
|
if (!map) return
|
|
|
|
e.originalEvent.stopPropagation()
|
|
e.preventDefault()
|
|
|
|
const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })
|
|
|
|
if (!features || features.length === 0) return
|
|
|
|
const clusterId: number = features[0].properties?.cluster_id as number
|
|
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(clusterId, (err, zoom) => {
|
|
if (err) return
|
|
|
|
map.easeTo({
|
|
center: (features[0].geometry as any).coordinates,
|
|
zoom: zoom ?? undefined,
|
|
})
|
|
})
|
|
},
|
|
[map],
|
|
)
|
|
|
|
const handleIncidentClick = useCallback(
|
|
(e: any) => {
|
|
if (!map) return
|
|
|
|
const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] })
|
|
if (!features || features.length === 0) return
|
|
|
|
const incident = features[0]
|
|
if (!incident.properties) return
|
|
|
|
e.originalEvent.stopPropagation()
|
|
e.preventDefault()
|
|
|
|
const incidentDetails = {
|
|
id: incident.properties.id,
|
|
district: incident.properties.district,
|
|
category: incident.properties.category,
|
|
type: incident.properties.incidentType,
|
|
description: incident.properties.description,
|
|
status: incident.properties?.status || "Unknown",
|
|
longitude: (incident.geometry as any).coordinates[0],
|
|
latitude: (incident.geometry as any).coordinates[1],
|
|
timestamp: new Date(),
|
|
}
|
|
|
|
console.log("Incident clicked:", incidentDetails)
|
|
|
|
const customEvent = new CustomEvent("incident_click", {
|
|
detail: incidentDetails,
|
|
bubbles: true,
|
|
})
|
|
|
|
if (map.getMap().getCanvas()) {
|
|
map.getMap().getCanvas().dispatchEvent(customEvent)
|
|
} else {
|
|
document.dispatchEvent(customEvent)
|
|
}
|
|
},
|
|
[map],
|
|
)
|
|
|
|
// Initialize crime clusters and points
|
|
useEffect(() => {
|
|
if (!map || !visible || crimes.length === 0) return
|
|
|
|
const onStyleLoad = () => {
|
|
if (!map) return
|
|
|
|
try {
|
|
// Get the first symbol layer
|
|
let firstSymbolId = beforeId
|
|
if (!firstSymbolId) {
|
|
const layers = map.getStyle().layers
|
|
for (const layer of layers) {
|
|
if (layer.type === "symbol") {
|
|
firstSymbolId = layer.id
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!map.getMap().getSource("crime-incidents")) {
|
|
const allIncidents = crimes.flatMap((crime) => {
|
|
let filteredIncidents = crime.crime_incidents
|
|
|
|
if (filterCategory !== "all") {
|
|
filteredIncidents = crime.crime_incidents.filter(
|
|
(incident) => incident.crime_categories.name === filterCategory,
|
|
)
|
|
}
|
|
|
|
return filteredIncidents.map((incident) => ({
|
|
type: "Feature" as const,
|
|
properties: {
|
|
id: incident.id,
|
|
district: crime.districts.name,
|
|
category: incident.crime_categories.name,
|
|
incidentType: incident.crime_categories.type,
|
|
level: crime.level,
|
|
description: incident.description,
|
|
},
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [incident.locations.longitude, incident.locations.latitude],
|
|
},
|
|
}))
|
|
})
|
|
|
|
map.getMap().addSource("crime-incidents", {
|
|
type: "geojson",
|
|
data: {
|
|
type: "FeatureCollection",
|
|
features: allIncidents,
|
|
},
|
|
cluster: true,
|
|
clusterMaxZoom: 14,
|
|
clusterRadius: 50,
|
|
})
|
|
|
|
if (!map.getMap().getLayer("clusters")) {
|
|
map.getMap().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: isTimelapsePlaying ? "none" : "visible",
|
|
},
|
|
},
|
|
firstSymbolId,
|
|
)
|
|
}
|
|
|
|
if (!map.getMap().getLayer("cluster-count")) {
|
|
map.getMap().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": isTimelapsePlaying ? "none" : "visible",
|
|
},
|
|
paint: {
|
|
"text-color": "#ffffff",
|
|
},
|
|
})
|
|
}
|
|
|
|
if (!map.getMap().getLayer("unclustered-point")) {
|
|
map.getMap().addLayer(
|
|
{
|
|
id: "unclustered-point",
|
|
type: "circle",
|
|
source: "crime-incidents",
|
|
filter: ["!", ["has", "point_count"]],
|
|
paint: {
|
|
"circle-color": "#11b4da",
|
|
"circle-radius": 8,
|
|
"circle-stroke-width": 1,
|
|
"circle-stroke-color": "#fff",
|
|
},
|
|
layout: {
|
|
visibility: isTimelapsePlaying ? "none" : "visible",
|
|
},
|
|
},
|
|
firstSymbolId,
|
|
)
|
|
}
|
|
|
|
// Add event handlers
|
|
map.on("mouseenter", "clusters", () => {
|
|
map.getCanvas().style.cursor = "pointer"
|
|
})
|
|
|
|
map.on("mouseleave", "clusters", () => {
|
|
map.getCanvas().style.cursor = ""
|
|
})
|
|
|
|
map.on("mouseenter", "unclustered-point", () => {
|
|
map.getCanvas().style.cursor = "pointer"
|
|
})
|
|
|
|
map.on("mouseleave", "unclustered-point", () => {
|
|
map.getCanvas().style.cursor = ""
|
|
})
|
|
|
|
// Attach click handlers
|
|
map.off("click", "clusters", handleClusterClick)
|
|
map.off("click", "unclustered-point", handleIncidentClick)
|
|
|
|
map.on("click", "clusters", handleClusterClick)
|
|
map.on("click", "unclustered-point", handleIncidentClick)
|
|
|
|
layersAdded.current = true
|
|
}
|
|
} catch (error) {
|
|
console.error("Error adding crime cluster layers:", error)
|
|
}
|
|
}
|
|
|
|
if (map.isStyleLoaded()) {
|
|
onStyleLoad()
|
|
} else {
|
|
map.once("style.load", onStyleLoad)
|
|
}
|
|
|
|
return () => {
|
|
if (map) {
|
|
map.off("click", "clusters", handleClusterClick)
|
|
map.off("click", "unclustered-point", handleIncidentClick)
|
|
}
|
|
}
|
|
}, [map, visible, crimes, filterCategory, handleClusterClick, handleIncidentClick, beforeId, isTimelapsePlaying])
|
|
|
|
// Update crime data when filters change
|
|
useEffect(() => {
|
|
if (!map || !map.getMap().getSource("crime-incidents")) return
|
|
|
|
try {
|
|
// If timeline is playing, hide all point/cluster layers to improve performance
|
|
if (isTimelapsePlaying) {
|
|
// Hide all incident points during timelapse
|
|
if (map.getMap().getLayer("clusters")) {
|
|
map.getMap().setLayoutProperty("clusters", "visibility", "none")
|
|
}
|
|
if (map.getMap().getLayer("unclustered-point")) {
|
|
map.getMap().setLayoutProperty("unclustered-point", "visibility", "none")
|
|
}
|
|
if (map.getMap().getLayer("cluster-count")) {
|
|
map.getMap().setLayoutProperty("cluster-count", "visibility", "none")
|
|
}
|
|
|
|
// Update the source with empty data to free up resources
|
|
; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
|
type: "FeatureCollection",
|
|
features: [],
|
|
})
|
|
} else {
|
|
// When not playing, show all layers again
|
|
if (map.getMap().getLayer("clusters")) {
|
|
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
|
|
}
|
|
if (map.getMap().getLayer("unclustered-point")) {
|
|
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
|
|
}
|
|
if (map.getMap().getLayer("cluster-count")) {
|
|
map.getMap().setLayoutProperty("cluster-count", "visibility", "visible")
|
|
}
|
|
|
|
// Restore detailed incidents when timelapse stops
|
|
const allIncidents = crimes.flatMap((crime) => {
|
|
if (!crime.crime_incidents) return []
|
|
|
|
let filteredIncidents = crime.crime_incidents
|
|
|
|
if (filterCategory !== "all") {
|
|
filteredIncidents = crime.crime_incidents.filter(
|
|
(incident) => incident.crime_categories && incident.crime_categories.name === filterCategory,
|
|
)
|
|
}
|
|
|
|
return filteredIncidents
|
|
.map((incident) => {
|
|
if (!incident.locations) {
|
|
console.warn("Missing location for incident:", incident.id)
|
|
return null
|
|
}
|
|
|
|
return {
|
|
type: "Feature" as const,
|
|
properties: {
|
|
id: incident.id,
|
|
district: crime.districts?.name || "Unknown",
|
|
category: incident.crime_categories?.name || "Unknown",
|
|
incidentType: incident.crime_categories?.type || "Unknown",
|
|
level: crime.level || "low",
|
|
description: incident.description || "",
|
|
},
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [incident.locations.longitude || 0, incident.locations.latitude || 0],
|
|
},
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
})
|
|
|
|
// Update the source with detailed data
|
|
; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
|
type: "FeatureCollection",
|
|
features: allIncidents as GeoJSON.Feature[],
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.error("Error updating incident data:", error)
|
|
}
|
|
}, [map, crimes, filterCategory, isTimelapsePlaying])
|
|
|
|
return null
|
|
}
|