diff --git a/sigap-website/app/_components/map/layers/recent-incidents-layer.tsx b/sigap-website/app/_components/map/layers/recent-incidents-layer.tsx index 2228176..2e914fd 100644 --- a/sigap-website/app/_components/map/layers/recent-incidents-layer.tsx +++ b/sigap-website/app/_components/map/layers/recent-incidents-layer.tsx @@ -2,19 +2,49 @@ 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 { BASE_BEARING, BASE_DURATION, BASE_PITCH, BASE_ZOOM, PITCH_3D, ZOOM_3D } from "@/app/_utils/const/map" import IncidentLogsPopup from "../pop-up/incident-logs-popup" +import type mapboxgl from "mapbox-gl" +import type { MapMouseEvent, MapGeoJSONFeature } from "react-map-gl/mapbox" -interface RecentIncidentsLayerProps { +interface IRecentIncidentsLayerProps { visible?: boolean - map: any + map: mapboxgl.Map incidents?: IIncidentLogs[] } -export default function RecentIncidentsLayer({ visible = false, map, incidents = [] }: RecentIncidentsLayerProps) { +// Define a proper structure for GeoJSON feature properties +interface IIncidentFeatureProperties { + id: string + role_id?: string + user_id?: string + name?: string + email?: string + telephone?: string + avatar?: string + role?: string + address?: string + description?: string + timestamp: string + category?: string + district?: string + severity?: string + status?: boolean | string + source?: string + isVeryRecent: boolean + timeDiff: number +} + +// Define a proper incident object type that will be set in state +interface IIncidentDetails extends Omit { + timestamp: Date + isVeryRecent?: boolean +} + +export default function RecentIncidentsLayer({ visible = false, map, incidents = [] }: IRecentIncidentsLayerProps) { const isInteractingWithMarker = useRef(false) const animationFrameRef = useRef(null) - const [selectedIncident, setSelectedIncident] = useState(null) + const [selectedIncident, setSelectedIncident] = useState(null) // Filter incidents from the last 24 hours const recentIncidents = incidents.filter((incident) => { @@ -30,7 +60,7 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = const twoHoursInMs = 2 * 60 * 60 * 1000 // 2 hours in milliseconds const handleIncidentClick = useCallback( - (e: any) => { + (e: MapMouseEvent & { features?: MapGeoJSONFeature[] }) => { if (!map) return const features = map.queryRenderedFeatures(e.point, { layers: ["recent-incidents"] }) @@ -48,40 +78,42 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = e.originalEvent.stopPropagation() e.preventDefault() - const incidentDetails = { - id: incident.properties.id, - description: incident.properties.description, - status: incident.properties?.status || "Active", - verified: incident.properties?.status, + const props = incident.properties as IIncidentFeatureProperties + + const IincidentDetails: IIncidentDetails = { + id: props.id, + description: props.description || "", + verified: Boolean(props.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, + timestamp: new Date(props.timestamp || Date.now()), + category: props.category || "Unknown", + address: props.address || "Unknown", + district: props.district || "Unknown", + severity: (props.severity === "Low" || props.severity === "Medium" || props.severity === "High") ? props.severity : "Unknown", + source: props.source || "Unknown", + user_id: props.user_id || "Unknown", + name: props.name || "Unknown", + email: props.email || "Unknown", + phone: props.telephone || "Unknown", + avatar: props.avatar || "Unknown", + role_id: props.role_id || "Unknown", + role: props.role || "Unknown", + isVeryRecent: props.isVeryRecent, + } // Fly to the incident location map.flyTo({ - center: [incidentDetails.longitude, incidentDetails.latitude], + center: [IincidentDetails.longitude, IincidentDetails.latitude], zoom: ZOOM_3D, bearing: BASE_BEARING, - pitch: BASE_PITCH, + pitch: PITCH_3D, duration: BASE_DURATION, }) // Set selected incident for the popup - setSelectedIncident(incidentDetails) + setSelectedIncident(IincidentDetails) // Reset the flag after a delay setTimeout(() => { @@ -93,8 +125,17 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = // 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]) useEffect(() => { if (!map || !visible) return @@ -102,17 +143,30 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = // Convert incidents to GeoJSON with an additional property for recency const now = new Date().getTime() - const recentData = { - type: "FeatureCollection" as const, + // Define our GeoJSON structure with proper typing + interface IncidentGeoJSON { + type: 'FeatureCollection' + features: Array<{ + type: 'Feature' + geometry: { + type: 'Point' + coordinates: [number, number] + } + properties: IIncidentFeatureProperties + }> + } + + const recentData: IncidentGeoJSON = { + type: "FeatureCollection", 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, + type: "Feature", geometry: { - type: "Point" as const, + type: "Point", coordinates: [incident.longitude, incident.latitude], }, properties: { @@ -132,8 +186,8 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = 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 + isVeryRecent, + timeDiff, }, } }), @@ -143,7 +197,8 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = try { // Check if source exists and update it if (map.getSource("recent-incidents-source")) { - ; (map.getSource("recent-incidents-source") as any).setData(recentData) + const source = map.getSource("recent-incidents-source") as mapboxgl.GeoJSONSource + source.setData(recentData) } else { // If not, add source map.addSource("recent-incidents-source", { @@ -271,7 +326,7 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = } // Create animation for very recent incidents - const animatePulse = () => { + const animatePing = () => { if (!map || !map.getLayer("very-recent-incidents-pulse")) { return } @@ -295,7 +350,7 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = ]) // Continue animation - animationFrameRef.current = requestAnimationFrame(animatePulse) + animationFrameRef.current = requestAnimationFrame(animatePing) } // Start animation if visible @@ -303,7 +358,7 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current) } - animationFrameRef.current = requestAnimationFrame(animatePulse) + animationFrameRef.current = requestAnimationFrame(animatePing) } // Ensure click handler is properly registered @@ -338,7 +393,7 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = cancelAnimationFrame(animationFrameRef.current) } } - }, [map, visible, recentIncidents, handleIncidentClick]) + }, [map, visible, recentIncidents, handleIncidentClick, twoHoursInMs]) // Close popup when layer becomes invisible useEffect(() => { diff --git a/sigap-website/app/_styles/ui.css b/sigap-website/app/_styles/ui.css index a15539e..ef95014 100644 --- a/sigap-website/app/_styles/ui.css +++ b/sigap-website/app/_styles/ui.css @@ -1184,17 +1184,12 @@ label#internal { ========================= */ .animate-ping { - animation: ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite; + animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite; } @keyframes ping { - 0% { - transform: translate(-50%, 100%) scale(0.5); - opacity: 1; - } - 75%, - 100% { - transform: translate(-50%, 100%) scale(2); + 75%, 100% { + transform: scale(2); opacity: 0; } }