430 lines
15 KiB
TypeScript
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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|