364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useCallback, useRef, useState } from "react"
|
|
import type { IIncidentLogs } from "@/app/_utils/types/crimes"
|
|
import { BASE_BEARING, BASE_DURATION, BASE_PITCH, ZOOM_3D } from "@/app/_utils/const/map"
|
|
import IncidentLogsPopup from "../pop-up/incident-logs-popup"
|
|
|
|
interface RecentIncidentsLayerProps {
|
|
visible?: boolean
|
|
map: any
|
|
incidents?: IIncidentLogs[]
|
|
}
|
|
|
|
export default function RecentIncidentsLayer({ visible = false, map, incidents = [] }: RecentIncidentsLayerProps) {
|
|
const isInteractingWithMarker = useRef(false)
|
|
const animationFrameRef = useRef<number | null>(null)
|
|
const [selectedIncident, setSelectedIncident] = useState<IIncidentLogs | null>(null)
|
|
|
|
// Filter incidents from the last 24 hours
|
|
const recentIncidents = incidents.filter((incident) => {
|
|
if (!incident.timestamp) return false
|
|
const incidentDate = new Date(incident.timestamp)
|
|
const now = new Date()
|
|
const timeDiff = now.getTime() - incidentDate.getTime()
|
|
// 86400000 = 24 hours in milliseconds
|
|
return timeDiff <= 86400000
|
|
})
|
|
|
|
// Split incidents into very recent (2 hours) and regular recent
|
|
const twoHoursInMs = 2 * 60 * 60 * 1000 // 2 hours in milliseconds
|
|
|
|
const handleIncidentClick = useCallback(
|
|
(e: any) => {
|
|
if (!map) return
|
|
|
|
const features = map.queryRenderedFeatures(e.point, { layers: ["recent-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
|
|
|
|
e.originalEvent.stopPropagation()
|
|
e.preventDefault()
|
|
|
|
const incidentDetails = {
|
|
id: incident.properties.id,
|
|
description: incident.properties.description,
|
|
status: incident.properties?.status || "Active",
|
|
verified: incident.properties?.status,
|
|
longitude: (incident.geometry as any).coordinates[0],
|
|
latitude: (incident.geometry as any).coordinates[1],
|
|
timestamp: new Date(incident.properties.timestamp || Date.now()),
|
|
category: incident.properties.category,
|
|
address: incident.properties.address,
|
|
district: incident.properties.district,
|
|
severity: incident.properties.severity,
|
|
source: incident.properties.source,
|
|
user_id: incident.properties.user_id,
|
|
name: incident.properties.name,
|
|
email: incident.properties.email,
|
|
phone: incident.properties.telephone,
|
|
avatar: incident.properties.avatar,
|
|
role_id: incident.properties.role_id,
|
|
role: incident.properties.role,
|
|
isVeryRecent: incident.properties.isVeryRecent,
|
|
}
|
|
|
|
// Fly to the incident location
|
|
map.flyTo({
|
|
center: [incidentDetails.longitude, incidentDetails.latitude],
|
|
zoom: ZOOM_3D,
|
|
bearing: BASE_BEARING,
|
|
pitch: BASE_PITCH,
|
|
duration: BASE_DURATION,
|
|
})
|
|
|
|
// Set selected incident for the popup
|
|
setSelectedIncident(incidentDetails)
|
|
|
|
// Reset the flag after a delay
|
|
setTimeout(() => {
|
|
isInteractingWithMarker.current = false
|
|
}, 5000)
|
|
},
|
|
[map],
|
|
)
|
|
|
|
// Handle popup close
|
|
const handleClosePopup = useCallback(() => {
|
|
setSelectedIncident(null)
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!map || !visible) return
|
|
|
|
// Convert incidents to GeoJSON with an additional property for recency
|
|
const now = new Date().getTime()
|
|
|
|
const recentData = {
|
|
type: "FeatureCollection" as const,
|
|
features: recentIncidents.map((incident) => {
|
|
const timestamp = incident.timestamp ? new Date(incident.timestamp).getTime() : now
|
|
const timeDiff = now - timestamp
|
|
const isVeryRecent = timeDiff <= twoHoursInMs
|
|
|
|
return {
|
|
type: "Feature" as const,
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: [incident.longitude, incident.latitude],
|
|
},
|
|
properties: {
|
|
id: incident.id,
|
|
role_id: incident.role_id,
|
|
user_id: incident.user_id,
|
|
name: incident.name,
|
|
email: incident.email,
|
|
telephone: incident.phone,
|
|
avatar: incident.avatar,
|
|
role: incident.role,
|
|
address: incident.address,
|
|
description: incident.description,
|
|
timestamp: incident.timestamp ? incident.timestamp.toString() : new Date().toString(),
|
|
category: incident.category,
|
|
district: incident.district,
|
|
severity: incident.severity,
|
|
status: incident.verified,
|
|
source: incident.source,
|
|
isVeryRecent: isVeryRecent, // Add this property to identify very recent incidents
|
|
timeDiff: timeDiff, // Time difference in milliseconds
|
|
},
|
|
}
|
|
}),
|
|
}
|
|
|
|
const setupLayerAndSource = () => {
|
|
try {
|
|
// Check if source exists and update it
|
|
if (map.getSource("recent-incidents-source")) {
|
|
; (map.getSource("recent-incidents-source") as any).setData(recentData)
|
|
} else {
|
|
// If not, add source
|
|
map.addSource("recent-incidents-source", {
|
|
type: "geojson",
|
|
data: recentData,
|
|
})
|
|
}
|
|
|
|
// Find first symbol layer for proper layering
|
|
const layers = map.getStyle().layers
|
|
let firstSymbolId: string | undefined
|
|
for (const layer of layers) {
|
|
if (layer.type === "symbol") {
|
|
firstSymbolId = layer.id
|
|
break
|
|
}
|
|
}
|
|
|
|
// Add the pulsing glow layer for very recent incidents (2 hours or less)
|
|
if (!map.getLayer("very-recent-incidents-pulse")) {
|
|
map.addLayer(
|
|
{
|
|
id: "very-recent-incidents-pulse",
|
|
type: "circle",
|
|
source: "recent-incidents-source",
|
|
filter: ["==", ["get", "isVeryRecent"], true],
|
|
paint: {
|
|
"circle-color": "#FF0000",
|
|
"circle-radius": [
|
|
"interpolate",
|
|
["linear"],
|
|
["zoom"],
|
|
7,
|
|
8,
|
|
12,
|
|
16,
|
|
15,
|
|
24,
|
|
],
|
|
"circle-opacity": 0.3,
|
|
"circle-stroke-width": 2,
|
|
"circle-stroke-color": "#FF0000",
|
|
"circle-stroke-opacity": 0.5,
|
|
},
|
|
layout: {
|
|
visibility: visible ? "visible" : "none",
|
|
},
|
|
},
|
|
firstSymbolId,
|
|
)
|
|
} else {
|
|
map.setLayoutProperty("very-recent-incidents-pulse", "visibility", visible ? "visible" : "none")
|
|
}
|
|
|
|
// Add regular recent incidents glow
|
|
if (!map.getLayer("recent-incidents-glow")) {
|
|
map.addLayer(
|
|
{
|
|
id: "recent-incidents-glow",
|
|
type: "circle",
|
|
source: "recent-incidents-source",
|
|
paint: {
|
|
"circle-color": "#FF5252",
|
|
"circle-radius": ["interpolate", ["linear"], ["zoom"], 7, 6, 12, 12, 15, 18],
|
|
"circle-opacity": 0.2,
|
|
"circle-blur": 1,
|
|
},
|
|
layout: {
|
|
visibility: visible ? "visible" : "none",
|
|
},
|
|
},
|
|
"very-recent-incidents-pulse",
|
|
)
|
|
} else {
|
|
map.setLayoutProperty("recent-incidents-glow", "visibility", visible ? "visible" : "none")
|
|
}
|
|
|
|
// Check if layer exists already for the main marker dots
|
|
if (!map.getLayer("recent-incidents")) {
|
|
map.addLayer(
|
|
{
|
|
id: "recent-incidents",
|
|
type: "circle",
|
|
source: "recent-incidents-source",
|
|
paint: {
|
|
"circle-color": [
|
|
"case",
|
|
["==", ["get", "isVeryRecent"], true],
|
|
"#FF0000", // Bright red for very recent
|
|
"#FF5252", // Standard red for older incidents
|
|
],
|
|
"circle-radius": [
|
|
"interpolate",
|
|
["linear"],
|
|
["zoom"],
|
|
7,
|
|
4,
|
|
12,
|
|
8,
|
|
15,
|
|
12,
|
|
],
|
|
"circle-stroke-width": 2,
|
|
"circle-stroke-color": "#FFFFFF",
|
|
"circle-opacity": 0.8,
|
|
},
|
|
layout: {
|
|
visibility: visible ? "visible" : "none",
|
|
},
|
|
},
|
|
"recent-incidents-glow",
|
|
)
|
|
|
|
// Add mouse events
|
|
map.on("mouseenter", "recent-incidents", () => {
|
|
map.getCanvas().style.cursor = "pointer"
|
|
})
|
|
|
|
map.on("mouseleave", "recent-incidents", () => {
|
|
map.getCanvas().style.cursor = ""
|
|
})
|
|
} else {
|
|
// Update existing layer visibility
|
|
map.setLayoutProperty("recent-incidents", "visibility", visible ? "visible" : "none")
|
|
}
|
|
|
|
// Create animation for very recent incidents
|
|
const animatePulse = () => {
|
|
if (!map || !map.getLayer("very-recent-incidents-pulse")) {
|
|
return
|
|
}
|
|
|
|
// Create a pulsing effect by changing the size and opacity
|
|
const pulseSize = (Date.now() % 2000) / 2000 // Values from 0 to 1 every 2 seconds
|
|
const pulseOpacity = 0.7 - pulseSize * 0.5 // Opacity oscillates between 0.2 and 0.7
|
|
const scaleFactor = 1 + pulseSize * 0.5 // Size oscillates between 1x and 1.5x
|
|
|
|
map.setPaintProperty("very-recent-incidents-pulse", "circle-opacity", pulseOpacity)
|
|
map.setPaintProperty("very-recent-incidents-pulse", "circle-radius", [
|
|
"interpolate",
|
|
["linear"],
|
|
["zoom"],
|
|
7,
|
|
8 * scaleFactor,
|
|
12,
|
|
16 * scaleFactor,
|
|
15,
|
|
24 * scaleFactor,
|
|
])
|
|
|
|
// Continue animation
|
|
animationFrameRef.current = requestAnimationFrame(animatePulse)
|
|
}
|
|
|
|
// Start animation if visible
|
|
if (visible) {
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.current)
|
|
}
|
|
animationFrameRef.current = requestAnimationFrame(animatePulse)
|
|
}
|
|
|
|
// Ensure click handler is properly registered
|
|
map.off("click", "recent-incidents", handleIncidentClick)
|
|
map.on("click", "recent-incidents", handleIncidentClick)
|
|
} catch (error) {
|
|
console.error("Error setting up recent incidents layer:", error)
|
|
}
|
|
}
|
|
|
|
// Check if style is loaded and set up layer accordingly
|
|
if (map.isStyleLoaded()) {
|
|
setupLayerAndSource()
|
|
} else {
|
|
map.once("style.load", setupLayerAndSource)
|
|
|
|
// Fallback
|
|
setTimeout(() => {
|
|
if (map.isStyleLoaded()) {
|
|
setupLayerAndSource()
|
|
} else {
|
|
console.warn("Map style still not loaded after timeout")
|
|
}
|
|
}, 1000)
|
|
}
|
|
|
|
return () => {
|
|
if (map) {
|
|
map.off("click", "recent-incidents", handleIncidentClick)
|
|
}
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.current)
|
|
}
|
|
}
|
|
}, [map, visible, recentIncidents, handleIncidentClick])
|
|
|
|
// Close popup when layer becomes invisible
|
|
useEffect(() => {
|
|
if (!visible) {
|
|
setSelectedIncident(null)
|
|
}
|
|
}, [visible])
|
|
|
|
return (
|
|
<>
|
|
{/* Popup component */}
|
|
{selectedIncident && (
|
|
<IncidentLogsPopup
|
|
longitude={selectedIncident.longitude}
|
|
latitude={selectedIncident.latitude}
|
|
onClose={handleClosePopup}
|
|
incident={selectedIncident}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|