MIF_E31221222/sigap-website/app/_components/map/layers/all-incidents-layer.tsx

430 lines
15 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ICrimes } from "@/app/_utils/types/crimes";
import {
BASE_BEARING,
BASE_DURATION,
BASE_PITCH,
BASE_ZOOM,
PITCH_3D,
ZOOM_3D,
} from "@/app/_utils/const/map";
import IncidentPopup from "../pop-up/incident-popup";
import type mapboxgl from "mapbox-gl";
import type { MapGeoJSONFeature, MapMouseEvent } from "react-map-gl/mapbox";
import { manageLayerVisibility } from "@/app/_utils/map/layer-visibility";
interface IAllIncidentsLayerProps {
visible?: boolean;
map: mapboxgl.Map | null;
crimes: ICrimes[];
filterCategory: string | "all";
}
interface IIncidentFeatureProperties {
id: string;
category: string;
description: string;
timestamp: string;
district: string;
district_id: string;
year: number;
month: number;
address: string | null;
latitude: number;
longitude: number;
}
interface IIncidentDetails {
id: string;
category: string;
description: string;
timestamp: Date;
district: string;
district_id: string;
year: number;
month: number;
address: string | null;
latitude: number;
longitude: number;
}
export default function AllIncidentsLayer(
{ visible = false, map, crimes = [], filterCategory = "all" }:
IAllIncidentsLayerProps,
) {
const isInteractingWithMarker = useRef(false);
const animationFrameRef = useRef<number | null>(null);
const [selectedIncident, setSelectedIncident] = useState<
IIncidentDetails | null
>(null);
// Define layer IDs for consistent management
const LAYER_IDS = [
"all-incidents-pulse",
"all-incidents-circles",
"all-incidents",
];
const handleIncidentClick = useCallback(
(e: MapMouseEvent & { features?: MapGeoJSONFeature[] }) => {
if (!map) return;
const features = map.queryRenderedFeatures(e.point, {
layers: ["all-incidents"],
});
if (!features || features.length === 0) return;
// Stop event propagation
e.originalEvent.stopPropagation();
e.preventDefault();
isInteractingWithMarker.current = true;
const incident = features[0];
if (!incident.properties) return;
const props = incident
.properties as unknown as IIncidentFeatureProperties;
const IincidentDetails: IIncidentDetails = {
id: props.id,
description: props.description,
category: props.category,
district: props.district,
district_id: props.district_id,
year: props.year,
month: props.month,
address: props.address,
latitude: props.latitude,
longitude: props.longitude,
timestamp: new Date(props.timestamp || Date.now()),
};
// Fly to the incident location
map.flyTo({
center: [IincidentDetails.longitude, IincidentDetails.latitude],
zoom: ZOOM_3D,
bearing: BASE_BEARING,
pitch: PITCH_3D,
duration: BASE_DURATION,
});
// Set selected incident for the popup
setSelectedIncident(IincidentDetails);
// Reset the flag after a delay
setTimeout(() => {
isInteractingWithMarker.current = false;
}, 1000);
},
[map],
);
// Handle popup close
const handleClosePopup = useCallback(() => {
if (!map) return;
map.easeTo({
zoom: BASE_ZOOM,
bearing: BASE_BEARING,
pitch: BASE_PITCH,
duration: BASE_DURATION,
});
setSelectedIncident(null);
}, [map]);
// Effect to manage layer visibility consistently
useEffect(() => {
const cleanup = manageLayerVisibility(map, LAYER_IDS, visible, () => {
// When layers become invisible, close any open popup
if (!visible) setSelectedIncident(null);
// Cancel animation frame when hiding the layer
if (!visible && animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
});
return cleanup;
}, [visible, map]);
useEffect(() => {
if (!map || !visible) return;
// Convert incidents to GeoJSON format
const allIncidents = crimes.flatMap((crime) => {
return crime.crime_incidents
.filter((incident) =>
// Apply category filter if specified
(filterCategory === "all" ||
incident.crime_categories?.name === filterCategory) &&
// Make sure we have valid location data
incident.locations?.latitude &&
incident.locations?.longitude
)
.map((incident) => ({
type: "Feature" as const,
geometry: {
type: "Point" as const,
coordinates: [
incident.locations!.longitude,
incident.locations!.latitude,
],
},
properties: {
id: incident.id,
description: incident.description || "No description",
timestamp: incident.timestamp?.toString() ||
new Date().toString(),
category: incident.crime_categories?.name || "Unknown",
district: crime.districts?.name || "Unknown",
district_id: crime.district_id,
year: crime.year,
month: crime.month,
address: incident.locations?.address || null,
latitude: incident.locations!.latitude,
longitude: incident.locations!.longitude,
},
}));
});
const incidentsGeoJSON = {
type: "FeatureCollection" as const,
features: allIncidents,
};
const setupLayersAndSources = () => {
try {
// Check if source already exists and update it
if (map.getSource("all-incidents-source")) {
const source = map.getSource(
"all-incidents-source",
) as mapboxgl.GeoJSONSource;
source.setData(incidentsGeoJSON);
} else {
// Add source if it doesn't exist
map.addSource("all-incidents-source", {
type: "geojson",
data: incidentsGeoJSON,
});
// Get first symbol layer for insertion order
const layers = map.getStyle().layers;
let firstSymbolId: string | undefined;
for (const layer of layers) {
if (layer.type === "symbol") {
firstSymbolId = layer.id;
break;
}
}
// Pulsing circle effect for very recent incidents
map.addLayer({
id: "all-incidents-pulse",
type: "circle",
source: "all-incidents-source",
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
10,
10,
15,
20,
],
"circle-color": [
"match",
["get", "category"],
"Theft",
"#FF5733",
"Assault",
"#C70039",
"Robbery",
"#900C3F",
"Burglary",
"#581845",
"Fraud",
"#FFC300",
"Homicide",
"#FF0000",
// Default color for other categories
"#2874A6",
],
"circle-opacity": 0.4,
"circle-blur": 0.6,
},
}, firstSymbolId);
// Background circle for all incidents
map.addLayer({
id: "all-incidents-circles",
type: "circle",
source: "all-incidents-source",
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
10,
5,
15,
10,
],
"circle-color": [
"match",
["get", "category"],
"Theft",
"#FF5733",
"Assault",
"#C70039",
"Robbery",
"#900C3F",
"Burglary",
"#581845",
"Fraud",
"#FFC300",
"Homicide",
"#FF0000",
// Default color for other categories
"#2874A6",
],
"circle-stroke-width": 1,
"circle-stroke-color": "#FFFFFF",
"circle-opacity": 0.6,
},
}, firstSymbolId);
// Main incident point
map.addLayer({
id: "all-incidents",
type: "circle",
source: "all-incidents-source",
paint: {
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
10,
3,
15,
6,
],
"circle-color": [
"match",
["get", "category"],
"Theft",
"#FF5733",
"Assault",
"#C70039",
"Robbery",
"#900C3F",
"Burglary",
"#581845",
"Fraud",
"#FFC300",
"Homicide",
"#FF0000",
// Default color for other categories
"#2874A6",
],
"circle-stroke-width": 1,
"circle-stroke-color": "#FFFFFF",
"circle-opacity": 1,
},
}, firstSymbolId);
}
// Add mouse events
map.on("mouseenter", "all-incidents", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "all-incidents", () => {
map.getCanvas().style.cursor = "";
});
map.on("click", "all-incidents", handleIncidentClick);
} catch (error) {
console.error("Error setting up all incidents layer:", error);
}
};
// Set up layers when the map is ready
if (map.isStyleLoaded()) {
setupLayersAndSources();
} else {
map.once("load", setupLayersAndSources);
}
// Start the pulse animation effect
const animatePulse = () => {
if (!map || !visible || !map.getLayer("all-incidents-pulse")) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
return;
}
const pulseSize = 10 + 5 * Math.sin(Date.now() / 500);
try {
map.setPaintProperty("all-incidents-pulse", "circle-radius", [
"interpolate",
["linear"],
["zoom"],
10,
pulseSize,
15,
pulseSize * 2,
]);
animationFrameRef.current = requestAnimationFrame(animatePulse);
} catch (error) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
}
};
animationFrameRef.current = requestAnimationFrame(animatePulse);
// Clean up event listeners and animation
return () => {
if (map) {
map.off("click", "all-incidents", handleIncidentClick);
map.off("mouseenter", "all-incidents", () => {
map.getCanvas().style.cursor = "pointer";
});
map.off("mouseleave", "all-incidents", () => {
map.getCanvas().style.cursor = "";
});
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
};
}, [map, visible, crimes, filterCategory, handleIncidentClick]);
return (
<>
{selectedIncident && (
<IncidentPopup
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={handleClosePopup}
incident={selectedIncident}
/>
)}
</>
);
}