From 58f033d0e4f1871f3297a4a384824ed0d9ba7b8c Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Wed, 14 May 2025 16:14:40 +0700 Subject: [PATCH] Refactor map layers and pop-ups for improved incident handling and UI updates - Removed unused CrimePopup component and replaced with IncidentPopup in layers.tsx - Consolidated incident click handling logic in UnitsLayer and RecentIncidentsLayer - Updated UnitsLayer to fetch nearest units and display them in the IncidentPopup - Enhanced UI for displaying nearby police units with loading states and better formatting - Cleaned up console logs for production readiness - Adjusted map flyTo parameters for better user experience - Added wave circle animations for incident markers --- .../units/_queries/queries.ts | 9 +- .../crime-management/units/action.ts | 51 ++ .../map/containers/alert-layer-container.tsx | 1 - .../_components/map/layers/cluster-layer.tsx | 94 +-- .../map/layers/ews-alert-layer.tsx | 56 +- .../map/layers/historical-incidents-layer.tsx | 6 +- .../app/_components/map/layers/layers.tsx | 158 +---- .../map/layers/recent-incidents-layer.tsx | 4 +- .../map/layers/uncluster-layer.tsx | 2 +- .../_components/map/layers/units-layer.tsx | 134 +++-- .../_components/map/pop-up/crime-popup.tsx | 24 +- .../_components/map/pop-up/incident-popup.tsx | 67 +-- .../app/_utils/map/custom-animated-popup.tsx | 556 +++++++++--------- 13 files changed, 547 insertions(+), 615 deletions(-) diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries.ts b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries.ts index 608d2ab..15df917 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries.ts @@ -1,6 +1,6 @@ import { IUnits } from '@/app/_utils/types/units'; import { useQuery } from '@tanstack/react-query'; -import { getUnits } from '../action'; +import { getNearestUnits, getUnits, INearestUnits } from '../action'; export const useGetUnitsQuery = () => { return useQuery({ @@ -8,3 +8,10 @@ export const useGetUnitsQuery = () => { queryFn: () => getUnits(), }); }; + +export const useGetNearestUnitsQuery = (lat: number, lon: number, max_results?: number) => { + return useQuery({ + queryKey: ['nearest-units', lat, lon], + queryFn: () => getNearestUnits(lat, lon, max_results), + }); +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/action.ts b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/action.ts index 1da85c7..231d6a3 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/action.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/units/action.ts @@ -1,11 +1,25 @@ 'use server'; +import { createClient } from '@/app/_utils/supabase/client'; import { IUnits } from '@/app/_utils/types/units'; import { getInjection } from '@/di/container'; import db from '@/prisma/db'; import { AuthenticationError } from '@/src/entities/errors/auth'; import { InputParseError } from '@/src/entities/errors/common'; +export interface INearestUnits { + code_unit: string; + name: string; + type: string; + address: string; + district_id: string; + lat_unit: number; + lon_unit: number; + distance_meters: number; +} + +const supabase = createClient(); + export async function getUnits(): Promise { const instrumentationService = getInjection('IInstrumentationService'); return await instrumentationService.instrumentServerAction( @@ -63,3 +77,40 @@ export async function getUnits(): Promise { } ); } + +export async function getNearestUnits(lat: number, lon: number, max_results: number = 5): Promise { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'District Crime Data', + { recordResponse: true }, + async () => { + try { + + const { data, error } = await supabase.rpc('nearby_units', { + lat, + lon, + max_results + }).select(); + + if (error) { + console.error('Error fetching nearest units:', error); + return []; + } + + if (!data) { + console.error('No data returned from RPC'); + return []; + } + + return data as INearestUnits[]; + + } catch (err) { + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + throw new Error( + 'An error happened. The developers have been notified. Please try again later.' + ); + } + } + ); +} \ No newline at end of file diff --git a/sigap-website/app/_components/map/containers/alert-layer-container.tsx b/sigap-website/app/_components/map/containers/alert-layer-container.tsx index 8051218..37e21dc 100644 --- a/sigap-website/app/_components/map/containers/alert-layer-container.tsx +++ b/sigap-website/app/_components/map/containers/alert-layer-container.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'; import mapboxgl from 'mapbox-gl'; -import RecentCrimesLayer from '../layers/recent-crimes-layer'; import EWSAlertLayer from '../layers/ews-alert-layer'; import { IIncidentLog } from '@/app/_utils/types/ews'; diff --git a/sigap-website/app/_components/map/layers/cluster-layer.tsx b/sigap-website/app/_components/map/layers/cluster-layer.tsx index 85af200..7f632d0 100644 --- a/sigap-website/app/_components/map/layers/cluster-layer.tsx +++ b/sigap-website/app/_components/map/layers/cluster-layer.tsx @@ -231,23 +231,23 @@ export default function ClusterLayer({ const props = feature.properties const coordinates = (feature.geometry as any).coordinates.slice() - if (props) { - if (props) { - const popupHTML = ` -
-

${props.district_name}

-
-

Total Crimes: ${props.crime_count}

-

Crime Level: ${props.level}

-

Year: ${props.year} - Month: ${props.month}

- ${filterCategory !== "all" ? `

Category: ${filterCategory}

` : ""} -
-
- ` + // if (props) { + // if (props) { + // const popupHTML = ` + //
+ //

${props.district_name}

+ //
+ //

Total Crimes: ${props.crime_count}

+ //

Crime Level: ${props.level}

+ //

Year: ${props.year} - Month: ${props.month}

+ // ${filterCategory !== "all" ? `

Category: ${filterCategory}

` : ""} + //
+ //
+ // ` - new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map) - } - } + // new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map) + // } + // } } } @@ -436,21 +436,21 @@ export default function ClusterLayer({ const props = feature.properties const coordinates = (feature.geometry as any).coordinates.slice() - if (props) { - const popupHTML = ` -
-

${props.district_name}

-
-

Total Crimes: ${props.crime_count}

-

Crime Level: ${props.level}

-

Year: ${props.year} - Month: ${props.month}

- ${filterCategory !== "all" ? `

Category: ${filterCategory}

` : ""} -
-
- ` + // if (props) { + // const popupHTML = ` + //
+ //

${props.district_name}

+ //
+ //

Total Crimes: ${props.crime_count}

+ //

Crime Level: ${props.level}

+ //

Year: ${props.year} - Month: ${props.month}

+ // ${filterCategory !== "all" ? `

Category: ${filterCategory}

` : ""} + //
+ //
+ // ` - new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map) - } + // new mapboxgl.Popup().setLngLat(coordinates).setHTML(popupHTML).addTo(map) + // } } } @@ -532,24 +532,24 @@ export default function ClusterLayer({ const props = feature.properties; const coordinates = (feature.geometry as any).coordinates.slice(); - if (props) { - const popupHTML = ` -
-

${props.district_name}

-
-

Total Crimes: ${props.crime_count}

-

Crime Level: ${props.level}

-

Year: ${props.year} - Month: ${props.month}

- ${filterCategory !== "all" ? `

Category: ${filterCategory}

` : ""} -
-
- `; + // if (props) { + // const popupHTML = ` + //
+ //

${props.district_name}

+ //
+ //

Total Crimes: ${props.crime_count}

+ //

Crime Level: ${props.level}

+ //

Year: ${props.year} - Month: ${props.month}

+ // ${filterCategory !== "all" ? `

Category: ${filterCategory}

` : ""} + //
+ //
+ // `; - new mapboxgl.Popup() - .setLngLat(coordinates) - .setHTML(popupHTML) - .addTo(map); - } + // // new mapboxgl.Popup() + // // .setLngLat(coordinates) + // // .setHTML(popupHTML) + // // .addTo(map); + // } } }; diff --git a/sigap-website/app/_components/map/layers/ews-alert-layer.tsx b/sigap-website/app/_components/map/layers/ews-alert-layer.tsx index b9f1711..b26c403 100644 --- a/sigap-website/app/_components/map/layers/ews-alert-layer.tsx +++ b/sigap-website/app/_components/map/layers/ews-alert-layer.tsx @@ -10,7 +10,7 @@ import DigitalClock from '../markers/digital-clock'; import { Badge } from '@/app/_components/ui/badge'; import { Button } from '@/app/_components/ui/button'; import { IconCancel } from '@tabler/icons-react'; -import { CustomAnimatedPopup } from '@/app/_utils/map/custom-animated-popup'; + interface EWSAlertLayerProps { map: mapboxgl.Map | null; @@ -245,32 +245,32 @@ export default function EWSAlertLayer({ ); // Create and attach the animated popup - const popup = new CustomAnimatedPopup({ - closeOnClick: false, - openingAnimation: { - duration: 300, - easing: 'ease-out', - transform: 'scale' - }, - closingAnimation: { - duration: 200, - easing: 'ease-in-out', - transform: 'scale' - } - }).setDOMContent(popupElement); + // const popup = new CustomAnimatedPopup({ + // closeOnClick: false, + // openingAnimation: { + // duration: 300, + // easing: 'ease-out', + // transform: 'scale' + // }, + // closingAnimation: { + // duration: 200, + // easing: 'ease-in-out', + // transform: 'scale' + // } + // }).setDOMContent(popupElement); - marker.setPopup(popup); - markersRef.current.set(incident.id, marker); + // marker.setPopup(popup); + // markersRef.current.set(incident.id, marker); - // Add wave circles around the incident point - if (map) { - popup.addWaveCircles(map, new mapboxgl.LngLat(longitude, latitude), { - color: incident.priority === 'high' ? '#ff0000' : - incident.priority === 'medium' ? '#ff9900' : '#0066ff', - maxRadius: 300, - count: 4 - }); - } + // // Add wave circles around the incident point + // if (map) { + // popup.addWaveCircles(map, new mapboxgl.LngLat(longitude, latitude), { + // color: incident.priority === 'high' ? '#ff0000' : + // incident.priority === 'medium' ? '#ff9900' : '#0066ff', + // maxRadius: 300, + // count: 4 + // }); + // } // Fly to the incident if it's new const isNewIncident = activeIncidents.length > 0 && @@ -292,9 +292,9 @@ export default function EWSAlertLayer({ map.getContainer().dispatchEvent(flyToEvent); // Auto-open popup for the newest incident - setTimeout(() => { - popup.addTo(map); - }, 2000); + // setTimeout(() => { + // popup.addTo(map); + // }, 2000); } }); diff --git a/sigap-website/app/_components/map/layers/historical-incidents-layer.tsx b/sigap-website/app/_components/map/layers/historical-incidents-layer.tsx index d82c32f..0755554 100644 --- a/sigap-website/app/_components/map/layers/historical-incidents-layer.tsx +++ b/sigap-website/app/_components/map/layers/historical-incidents-layer.tsx @@ -67,7 +67,7 @@ export default function HistoricalIncidentsLayer({ year: incident.properties.year, }; - console.log("Historical incident clicked:", incidentDetails); + // console.log("Historical incident clicked:", incidentDetails); // Ensure markers stay visible when clicking on them if (map.getLayer("historical-incidents")) { @@ -103,7 +103,7 @@ export default function HistoricalIncidentsLayer({ useEffect(() => { if (!map || !visible) return; - console.log("Setting up historical incidents layer"); + // console.log("Setting up historical incidents layer"); // Filter incidents from 2020 to current year const historicalData = { @@ -148,7 +148,7 @@ export default function HistoricalIncidentsLayer({ ), }; - console.log(`Found ${historicalData.features.length} historical incidents from 2020 to ${currentYear}`); + // console.log(`Found ${historicalData.features.length} historical incidents from 2020 to ${currentYear}`); const setupLayerAndSource = () => { try { diff --git a/sigap-website/app/_components/map/layers/layers.tsx b/sigap-website/app/_components/map/layers/layers.tsx index d811ad2..2031f63 100644 --- a/sigap-website/app/_components/map/layers/layers.tsx +++ b/sigap-website/app/_components/map/layers/layers.tsx @@ -19,7 +19,7 @@ import type { ITooltipsControl } from "../controls/top/tooltips" import type { IUnits } from "@/app/_utils/types/units" import UnitsLayer from "./units-layer" import DistrictFillLineLayer from "./district-layer" -import CrimePopup from "../pop-up/crime-popup" + import TimezoneLayer from "./timezone" import FaultLinesLayer from "./fault-lines" import EWSAlertLayer from "./ews-alert-layer" @@ -28,6 +28,7 @@ import PanicButtonDemo from "../controls/panic-button-demo" import type { IIncidentLog } from "@/app/_utils/types/ews" import { addMockIncident, getAllIncidents, resolveIncident } from "@/app/_utils/mock/ews-data" import RecentIncidentsLayer from "./recent-incidents-layer" +import IncidentPopup from "../pop-up/incident-popup" // Interface for crime incident interface ICrimeIncident { @@ -247,24 +248,19 @@ export default function Layers({ } const handleCloseDistrictPopup = useCallback(() => { - console.log("Closing district popup") + // console.log("Closing district popup") animateExtrusionDown() handlePopupClose() }, [handlePopupClose, animateExtrusionDown]) - const handleCloseIncidentPopup = useCallback(() => { - console.log("Closing incident popup") - handlePopupClose() - }, [handlePopupClose]) - const handleDistrictClick = useCallback( (feature: IDistrictFeature) => { - console.log("District clicked:", feature) + // console.log("District clicked:", feature) // If we're currently interacting with a marker, don't process district click if (isInteractingWithMarker.current) { - console.log("Ignoring district click because we're interacting with a marker") + // console.log("Ignoring district click because we're interacting with a marker") return } @@ -325,139 +321,6 @@ export default function Layers({ } }, [mapboxMap, map]) - useEffect(() => { - if (!mapboxMap) return - - const handleIncidentClickEvent = (e: Event) => { - const customEvent = e as CustomEvent - console.log("Received incident_click event in layers:", customEvent.detail) - - if (!customEvent.detail) { - console.error("Empty incident click event data") - return - } - - // Set the marker interaction flag to prevent district selection - isInteractingWithMarker.current = true - - const incidentId = customEvent.detail.id || customEvent.detail.incidentId || customEvent.detail.incident_id - - if (!incidentId) { - console.error("No incident ID found in event data:", customEvent.detail) - return - } - - console.log("Looking for incident with ID:", incidentId) - - let foundIncident: ICrimeIncident | undefined - - if ( - customEvent.detail.latitude !== undefined && - customEvent.detail.longitude !== undefined && - customEvent.detail.category !== undefined - ) { - foundIncident = { - id: incidentId, - district: customEvent.detail.district, - category: customEvent.detail.category, - type_category: customEvent.detail.type, - description: customEvent.detail.description, - status: customEvent.detail.status || "Unknown", - timestamp: customEvent.detail.timestamp ? new Date(customEvent.detail.timestamp) : undefined, - latitude: customEvent.detail.latitude, - longitude: customEvent.detail.longitude, - address: customEvent.detail.address, - } - } else { - for (const crime of crimes) { - for (const incident of crime.crime_incidents) { - if (incident.id === incidentId || incident.id?.toString() === incidentId?.toString()) { - console.log("Found matching incident:", incident) - foundIncident = { - id: incident.id, - district: crime.districts.name, - description: incident.description, - status: incident.status || "unknown", - timestamp: incident.timestamp, - category: incident.crime_categories.name, - type_category: incident.crime_categories.type, - address: incident.locations.address, - latitude: incident.locations.latitude, - longitude: incident.locations.longitude, - } - break - } - } - if (foundIncident) break - } - } - - if (!foundIncident) { - console.error("Could not find incident with ID:", incidentId) - isInteractingWithMarker.current = false - return - } - - if (!foundIncident.latitude || !foundIncident.longitude) { - console.error("Found incident has invalid coordinates:", foundIncident) - isInteractingWithMarker.current = false - return - } - - console.log("Setting selected incident:", foundIncident) - - // Clear district selection when showing an incident - setSelectedDistrict(null) - selectedDistrictRef.current = null - setFocusedDistrictId(null) - - setSelectedIncident(foundIncident) - - // Reset the marker interaction flag after a delay - setTimeout(() => { - isInteractingWithMarker.current = false - }, 1000) - } - - mapboxMap.getCanvas().addEventListener("incident_click", handleIncidentClickEvent as EventListener) - document.addEventListener("incident_click", handleIncidentClickEvent as EventListener) - - console.log("Set up incident click event listener") - - return () => { - console.log("Removing incident click event listener") - if (mapboxMap && mapboxMap.getCanvas()) { - mapboxMap.getCanvas().removeEventListener("incident_click", handleIncidentClickEvent as EventListener) - } - document.removeEventListener("incident_click", handleIncidentClickEvent as EventListener) - } - }, [mapboxMap, crimes, setFocusedDistrictId]) - - // Add a listener for unit clicks to set the marker interaction flag - useEffect(() => { - if (!mapboxMap) return - - const handleUnitClickEvent = (e: Event) => { - // Set the marker interaction flag to prevent district selection - isInteractingWithMarker.current = true - - // Reset the flag after a delay - setTimeout(() => { - isInteractingWithMarker.current = false - }, 1000) - } - - mapboxMap.getCanvas().addEventListener("unit_click", handleUnitClickEvent as EventListener) - document.addEventListener("unit_click", handleUnitClickEvent as EventListener) - - return () => { - if (mapboxMap && mapboxMap.getCanvas()) { - mapboxMap.getCanvas().removeEventListener("unit_click", handleUnitClickEvent as EventListener) - } - document.removeEventListener("unit_click", handleUnitClickEvent as EventListener) - } - }, [mapboxMap]) - useEffect(() => { if (selectedDistrictRef.current) { const districtId = selectedDistrictRef.current.id @@ -540,7 +403,7 @@ export default function Layers({ }, [crimes, filterCategory, year, month, crimeDataByDistrict]) const handleSetFocusedDistrictId = useCallback((id: string | null, isMarkerClick = false) => { - console.log("Setting focused district ID:", id, "from marker click:", isMarkerClick) + // console.log("Setting focused district ID:", id, "from marker click:", isMarkerClick) // If this is from a marker click, set the marker interaction flag if (isMarkerClick) { @@ -683,14 +546,7 @@ export default function Layers({ )} - {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( - - )} + ) } 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 297fbbd..621f2c0 100644 --- a/sigap-website/app/_components/map/layers/recent-incidents-layer.tsx +++ b/sigap-website/app/_components/map/layers/recent-incidents-layer.tsx @@ -51,7 +51,7 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = category: incident.properties.category, } - console.log("Recent incident clicked:", incidentDetails) + // console.log("Recent incident clicked:", incidentDetails) // Ensure markers stay visible if (map.getLayer("recent-incidents")) { @@ -87,7 +87,7 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents = useEffect(() => { if (!map || !visible) return - console.log(`Setting up recent incidents layer with ${recentIncidents.length} incidents from the last 24 hours`) + // console.log(`Setting up recent incidents layer with ${recentIncidents.length} incidents from the last 24 hours`) // Convert incidents to GeoJSON const recentData = { diff --git a/sigap-website/app/_components/map/layers/uncluster-layer.tsx b/sigap-website/app/_components/map/layers/uncluster-layer.tsx index 53eeb7c..5cbcfe1 100644 --- a/sigap-website/app/_components/map/layers/uncluster-layer.tsx +++ b/sigap-website/app/_components/map/layers/uncluster-layer.tsx @@ -41,7 +41,7 @@ export default function UnclusteredPointLayer({ timestamp: new Date(incident.properties.timestamp || Date.now()), } - console.log("Incident clicked:", incidentDetails) + // console.log("Incident clicked:", incidentDetails) // Ensure markers stay visible when clicking on them if (map.getLayer("unclustered-point")) { diff --git a/sigap-website/app/_components/map/layers/units-layer.tsx b/sigap-website/app/_components/map/layers/units-layer.tsx index 30bbe4f..ffe9def 100644 --- a/sigap-website/app/_components/map/layers/units-layer.tsx +++ b/sigap-website/app/_components/map/layers/units-layer.tsx @@ -8,6 +8,10 @@ import type mapboxgl from "mapbox-gl" import { generateCategoryColorMap } from "@/app/_utils/colors" import UnitPopup from "../pop-up/unit-popup" + +import { BASE_BEARING, BASE_DURATION, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map" +import { INearestUnits } from "@/app/(pages)/(admin)/dashboard/crime-management/units/action" +import { useGetNearestUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries" import IncidentPopup from "../pop-up/incident-popup" interface UnitsLayerProps { @@ -39,6 +43,13 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible const [unitIncident, setUnitIncident] = useState([]) const [isLoading, setIsLoading] = useState(false) + const [incidentCoords, setIncidentCoords] = useState<{ lat: number, lon: number } | null>(null) + const { data: nearestUnits, isLoading: isLoadingNearestUnits } = useGetNearestUnitsQuery( + incidentCoords?.lat ?? 0, + incidentCoords?.lon ?? 0, + 5 + ) + // Use either provided units or loaded units const unitsData = useMemo(() => { return units.length > 0 ? units : loadedUnits || [] @@ -64,18 +75,9 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible // Process units data to GeoJSON format const unitsGeoJSON = useMemo(() => { - console.log("Units data being processed:", unitsData) // Debug log - return { type: "FeatureCollection" as const, features: unitsData.map((unit) => { - // Debug log for individual units - console.log("Processing unit:", unit.code_unit, unit.name, { - longitude: unit.longitude, - latitude: unit.latitude, - district: unit.district_name, - }) - return { type: "Feature" as const, properties: { @@ -235,6 +237,7 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible // Find the unit in our data const unit = unitsData.find((u) => u.code_unit === properties.id) + if (!unit) { console.log("Unit not found in data:", properties.id) return @@ -245,9 +248,11 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible // Find all incidents in the same district as the unit const districtIncidents: IDistrictIncidents[] = [] crimes.forEach((crime) => { - // Check if this crime is in the same district as the unit - if (selectedUnit?.code_unit === unit.code_unit) { + console.log("Processing crime:", crime.district_id, unit.district_id) // Debug log + + // Check if this crime is in the same district as the unit + if (crime.district_id === unit.district_id) { crime.crime_incidents.forEach((incident) => { if (incident.locations && typeof incident.locations.distance_to_unit !== "undefined") { districtIncidents.push({ @@ -265,7 +270,7 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible // Sort by distance (closest first) districtIncidents.sort((a, b) => a.distance_meters - b.distance_meters) - console.log("Sorted district incidents:", districtIncidents) + // console.log("Sorted district incidents:", districtIncidents) // Update the state with the distance results setUnitIncident(districtIncidents) @@ -274,11 +279,11 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible // Fly to the unit location map.flyTo({ center: [unit.longitude || 0, unit.latitude || 0], - zoom: 14, + zoom: 12.5, pitch: 45, - bearing: 0, - duration: 2000, - }) + bearing: BASE_BEARING, + duration: BASE_DURATION, + }) // Set the selected unit and query parameters setSelectedUnit(unit) @@ -339,10 +344,10 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible // Fly to the incident location map.flyTo({ center: [longitude, latitude], - zoom: 15, + zoom: 16, pitch: 45, - bearing: 0, - duration: 2000, + bearing: BASE_BEARING, + duration: BASE_DURATION, }) // Create incident object from properties @@ -358,19 +363,23 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible latitude, } + // Debug log + console.log("Incident clicked:", incident) + // Set the selected incident and query parameters setSelectedIncident(incident) setSelectedUnit(null) // Clear any selected unit setSelectedEntityId(properties.id) setIsUnitSelected(false) setSelectedDistrictId(properties.district_id) + setIncidentCoords({ lat: latitude, lon: longitude }) // Highlight the connected lines for this incident if (map.getLayer("units-connection-lines")) { map.setFilter("units-connection-lines", ["==", ["get", "incident_id"], properties.id]) } - // Dispatch a custom event for other components to react to + // Dispatch a custom event for other components to react to const customEvent = new CustomEvent("incident_click", { detail: { id: properties.id, @@ -428,10 +437,11 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible useEffect(() => { if (!map || !visible) return - // Debug log to confirm map layers - console.log( - "Available map layers:", - map.getStyle().layers?.map((l) => l.id), + // Debug log untuk memeriksa keberadaan layer + console.log("Setting up event handlers, map layers:", + map.getStyle().layers?.filter(l => + l.id === "units-points" || l.id === "incidents-points" + ).map(l => l.id) ) // Define event handlers that can be referenced for both adding and removing @@ -443,27 +453,51 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible map.getCanvas().style.cursor = "" } - // Add click event for units-points layer - if (map.getLayer("units-points")) { - map.off("click", "units-points", unitClickHandler) - map.on("click", "units-points", unitClickHandler) + // Fungsi untuk setup event handler + const setupHandlers = () => { + // Add click event for units-points layer + if (map.getLayer("units-points")) { + map.off("click", "units-points", unitClickHandler) + map.on("click", "units-points", unitClickHandler) + map.on("mouseenter", "units-points", handleMouseEnter) + map.on("mouseleave", "units-points", handleMouseLeave) + console.log("✅ Unit points handler attached") + } else { + console.log("❌ units-points layer not found") + } - // Change cursor on hover - map.on("mouseenter", "units-points", handleMouseEnter) - map.on("mouseleave", "units-points", handleMouseLeave) + // Add click event for incidents-points layer + if (map.getLayer("incidents-points")) { + map.off("click", "incidents-points", incidentClickHandler) + map.on("click", "incidents-points", incidentClickHandler) + map.on("mouseenter", "incidents-points", handleMouseEnter) + map.on("mouseleave", "incidents-points", handleMouseLeave) + console.log("✅ Incident points handler attached") + } else { + console.log("❌ incidents-points layer not found") + } } - // Add click event for incidents-points layer - if (map.getLayer("incidents-points")) { - map.off("click", "incidents-points", incidentClickHandler) - map.on("click", "incidents-points", incidentClickHandler) + // Setup handlers langsung + setupHandlers() - // Change cursor on hover - map.on("mouseenter", "incidents-points", handleMouseEnter) - map.on("mouseleave", "incidents-points", handleMouseLeave) - } + // Safety check: pastikan handler terpasang setelah layer mungkin dimuat + const checkLayersTimeout = setTimeout(() => { + setupHandlers() + }, 1000) + + // Listen for style.load event to reattach handlers setelah perubahan style + map.on('style.load', setupHandlers) + map.on('sourcedata', (e) => { + if (e.sourceId === 'incidents-source' && e.isSourceLoaded && map.getLayer('incidents-points')) { + setupHandlers() + } + }) return () => { + clearTimeout(checkLayersTimeout) + map.off('style.load', setupHandlers) + if (map) { if (map.getLayer("units-points")) { map.off("click", "units-points", unitClickHandler) @@ -482,6 +516,7 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible // Reset map filters when popup is closed const handleClosePopup = useCallback(() => { + console.log("Closing popup, clearing selected states") setSelectedUnit(null) setSelectedIncident(null) setSelectedEntityId(undefined) @@ -490,6 +525,14 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible setIsLoading(false) if (map && map.getLayer("units-connection-lines")) { + map.easeTo({ + zoom: BASE_ZOOM, + pitch: BASE_PITCH, + bearing: BASE_BEARING, + duration: BASE_DURATION, + easing: (t) => t * (2 - t), + }) + map.setFilter("units-connection-lines", ["has", "unit_id"]) } }, [map]) @@ -501,6 +544,15 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible } }, [visible, handleClosePopup]) + // Debug untuk komponen render + useEffect(() => { + console.log("Render state:", { + selectedUnit: selectedUnit?.code_unit, + selectedIncident: selectedIncident?.id, + visible + }) + }, [selectedUnit, selectedIncident, visible]) + if (!visible) return null return ( @@ -599,8 +651,10 @@ export default function UnitsLayer({ crimes, units = [], filterCategory, visible latitude={selectedIncident.latitude} onClose={handleClosePopup} incident={selectedIncident} + nearestUnit={nearestUnits} + isLoadingNearestUnit={isLoadingNearestUnits} /> )} - + ) } diff --git a/sigap-website/app/_components/map/pop-up/crime-popup.tsx b/sigap-website/app/_components/map/pop-up/crime-popup.tsx index 90f034c..d6a5e51 100644 --- a/sigap-website/app/_components/map/pop-up/crime-popup.tsx +++ b/sigap-website/app/_components/map/pop-up/crime-popup.tsx @@ -25,7 +25,7 @@ interface IncidentPopupProps { } } -export default function IncidentPopup({ longitude, latitude, onClose, incident }: IncidentPopupProps) { +export default function CrimePopup({ longitude, latitude, onClose, incident }: IncidentPopupProps) { const formatDate = (date?: Date) => { if (!date) return "Unknown date" return new Date(date).toLocaleDateString() @@ -178,28 +178,6 @@ export default function IncidentPopup({ longitude, latitude, onClose, incident } - {/* Connection line */} -
- {/* Connection dot */} -
) diff --git a/sigap-website/app/_components/map/pop-up/incident-popup.tsx b/sigap-website/app/_components/map/pop-up/incident-popup.tsx index 78d1fbd..ce8e2d5 100644 --- a/sigap-website/app/_components/map/pop-up/incident-popup.tsx +++ b/sigap-website/app/_components/map/pop-up/incident-popup.tsx @@ -5,10 +5,11 @@ import { Badge } from "@/app/_components/ui/badge" import { Card } from "@/app/_components/ui/card" import { Separator } from "@/app/_components/ui/separator" import { Button } from "@/app/_components/ui/button" -import { MapPin, AlertTriangle, Calendar, Clock, Bookmark, Navigation, X, FileText } from "lucide-react" +import { MapPin, AlertTriangle, Calendar, Clock, Bookmark, Navigation, X, FileText, Shield } from "lucide-react" import { IDistanceResult } from "@/app/_utils/types/crimes" import { ScrollArea } from "@/app/_components/ui/scroll-area" import { Skeleton } from "@/app/_components/ui/skeleton" +import { INearestUnits } from "@/app/(pages)/(admin)/dashboard/crime-management/units/action" interface IncidentPopupProps { longitude: number @@ -22,8 +23,8 @@ interface IncidentPopupProps { district?: string district_id?: string } - distances?: IDistanceResult[] - isLoadingDistances?: boolean + nearestUnit?: INearestUnits[] + isLoadingNearestUnit?: boolean } export default function IncidentPopup({ @@ -31,8 +32,8 @@ export default function IncidentPopup({ latitude, onClose, incident, - distances = [], - isLoadingDistances = false + nearestUnit = [], + isLoadingNearestUnit = false }: IncidentPopupProps) { const formatDate = (date?: Date | string) => { @@ -130,31 +131,39 @@ export default function IncidentPopup({ )}
- {/* Distances to police units section */} + {/* NearestUnit to police nearestUnits section */}
-

Nearby Police Units

+

+ + Nearby Units +

- {isLoadingDistances ? ( + {isLoadingNearestUnit ? (
- ) : distances.length > 0 ? ( - + ) : nearestUnit.length > 0 ? ( +
- {distances.map((item) => ( -
+ {nearestUnit.map((unit) => ( +
-

{item.unit_name || "Unknown Unit"}

-

- {item.unit_type || "Police Unit"} +

+ {unit.name || "Unknown"} + + ({unit.type || "Unknown type"}) + +

+

+ {unit.address || "No address"}

- - {formatDistance(item.distance_meters)} + + {formatDistance(unit.distance_meters)}
))} @@ -162,7 +171,7 @@ export default function IncidentPopup({ ) : (

- No police units data available + No nearby units found

)}
@@ -176,28 +185,6 @@ export default function IncidentPopup({
- {/* Connection line */} -
- {/* Connection dot */} -
) diff --git a/sigap-website/app/_utils/map/custom-animated-popup.tsx b/sigap-website/app/_utils/map/custom-animated-popup.tsx index 367e591..c107d63 100644 --- a/sigap-website/app/_utils/map/custom-animated-popup.tsx +++ b/sigap-website/app/_utils/map/custom-animated-popup.tsx @@ -1,323 +1,323 @@ -import mapboxgl from 'mapbox-gl'; +// import mapboxgl from 'mapbox-gl'; -interface AnimationOptions { - duration: number; - easing: string; - transform: string; -} +// interface AnimationOptions { +// duration: number; +// easing: string; +// transform: string; +// } -interface CustomPopupOptions extends mapboxgl.PopupOptions { - openingAnimation?: AnimationOptions; - closingAnimation?: AnimationOptions; -} +// interface CustomPopupOptions extends mapboxgl.PopupOptions { +// openingAnimation?: AnimationOptions; +// closingAnimation?: AnimationOptions; +// } -// Extend the native Mapbox Popup -export class CustomAnimatedPopup extends mapboxgl.Popup { - private openingAnimation: AnimationOptions; - private closingAnimation: AnimationOptions; - private animating = false; +// // Extend the native Mapbox Popup +// export class CustomAnimatedPopup extends mapboxgl.Popup { +// private openingAnimation: AnimationOptions; +// private closingAnimation: AnimationOptions; +// private animating = false; - constructor(options: CustomPopupOptions = {}) { - // Extract animation options and pass the rest to the parent class - const { - openingAnimation, - closingAnimation, - className, - ...mapboxOptions - } = options; +// constructor(options: CustomPopupOptions = {}) { +// // Extract animation options and pass the rest to the parent class +// const { +// openingAnimation, +// closingAnimation, +// className, +// ...mapboxOptions +// } = options; - // Add our custom class to the className - const customClassName = `custom-animated-popup ${className || ''}`.trim(); +// // Add our custom class to the className +// const customClassName = `custom-animated-popup ${className || ''}`.trim(); - // Call the parent constructor - super({ - ...mapboxOptions, - className: customClassName, - }); +// // Call the parent constructor +// super({ +// ...mapboxOptions, +// className: customClassName, +// }); - // Store animation options - this.openingAnimation = openingAnimation || { - duration: 300, - easing: 'ease-out', - transform: 'scale' - }; +// // Store animation options +// this.openingAnimation = openingAnimation || { +// duration: 300, +// easing: 'ease-out', +// transform: 'scale' +// }; - this.closingAnimation = closingAnimation || { - duration: 200, - easing: 'ease-in-out', - transform: 'scale' - }; +// this.closingAnimation = closingAnimation || { +// duration: 200, +// easing: 'ease-in-out', +// transform: 'scale' +// }; - // Override the parent's add method - const parentAdd = this.addTo; - this.addTo = (map: mapboxgl.Map) => { - // Call the parent method first - parentAdd.call(this, map); +// // Override the parent's add method +// const parentAdd = this.addTo; +// this.addTo = (map: mapboxgl.Map) => { +// // Call the parent method first +// parentAdd.call(this, map); - // Apply animation after a short delay to ensure the element is in the DOM - setTimeout(() => this.animateOpen(), 10); +// // Apply animation after a short delay to ensure the element is in the DOM +// setTimeout(() => this.animateOpen(), 10); - return this; - }; - } +// return this; +// }; +// } - // Override the remove method to add animation - remove(): this { - if (this.animating) { - return this; - } +// // Override the remove method to add animation +// remove(): this { +// if (this.animating) { +// return this; +// } - this.animateClose(() => { - super.remove(); - }); +// this.animateClose(() => { +// super.remove(); +// }); - return this; - } +// return this; +// } - // Animation methods - private animateOpen(): void { - const container = this._container; - if (!container) return; +// // Animation methods +// private animateOpen(): void { +// const container = this._container; +// if (!container) return; - // Apply initial state - container.style.opacity = '0'; - container.style.transform = 'scale(0.8)'; - container.style.transition = ` - opacity ${this.openingAnimation.duration}ms ${this.openingAnimation.easing}, - transform ${this.openingAnimation.duration}ms ${this.openingAnimation.easing} - `; +// // Apply initial state +// container.style.opacity = '0'; +// container.style.transform = 'scale(0.8)'; +// container.style.transition = ` +// opacity ${this.openingAnimation.duration}ms ${this.openingAnimation.easing}, +// transform ${this.openingAnimation.duration}ms ${this.openingAnimation.easing} +// `; - // Force reflow - void container.offsetHeight; +// // Force reflow +// void container.offsetHeight; - // Apply final state to trigger animation - container.style.opacity = '1'; - container.style.transform = 'scale(1)'; - } +// // Apply final state to trigger animation +// container.style.opacity = '1'; +// container.style.transform = 'scale(1)'; +// } - private animateClose(callback: () => void): void { - const container = this._container; - if (!container) { - callback(); - return; - } +// private animateClose(callback: () => void): void { +// const container = this._container; +// if (!container) { +// callback(); +// return; +// } - this.animating = true; +// this.animating = true; - // Setup transition - container.style.transition = ` - opacity ${this.closingAnimation.duration}ms ${this.closingAnimation.easing}, - transform ${this.closingAnimation.duration}ms ${this.closingAnimation.easing} - `; +// // Setup transition +// container.style.transition = ` +// opacity ${this.closingAnimation.duration}ms ${this.closingAnimation.easing}, +// transform ${this.closingAnimation.duration}ms ${this.closingAnimation.easing} +// `; - // Apply closing animation - container.style.opacity = '0'; - container.style.transform = 'scale(0.8)'; +// // Apply closing animation +// container.style.opacity = '0'; +// container.style.transform = 'scale(0.8)'; - // Execute callback after animation completes - setTimeout(() => { - this.animating = false; - callback(); - }, this.closingAnimation.duration); - } +// // Execute callback after animation completes +// setTimeout(() => { +// this.animating = false; +// callback(); +// }, this.closingAnimation.duration); +// } - // Add method to create expanding wave circles - addWaveCircles(map: mapboxgl.Map, lngLat: mapboxgl.LngLat, options: { - color?: string, - maxRadius?: number, - duration?: number, - count?: number, - showCenter?: boolean - } = {}): void { - const { - color = 'red', - maxRadius = 80, // Reduce max radius for less "over" effect - duration = 2000, // Faster animation - count = 2, // Fewer circles - showCenter = true - } = options; +// // Add method to create expanding wave circles +// addWaveCircles(map: mapboxgl.Map, lngLat: mapboxgl.LngLat, options: { +// color?: string, +// maxRadius?: number, +// duration?: number, +// count?: number, +// showCenter?: boolean +// } = {}): void { +// const { +// color = 'red', +// maxRadius = 80, // Reduce max radius for less "over" effect +// duration = 2000, // Faster animation +// count = 2, // Fewer circles +// showCenter = true +// } = options; - const sourceId = `wave-circles-${Math.random().toString(36).substring(2, 9)}`; +// const sourceId = `wave-circles-${Math.random().toString(36).substring(2, 9)}`; - if (!map.getSource(sourceId)) { - map.addSource(sourceId, { - type: 'geojson', - data: { - type: 'FeatureCollection', - features: [{ - type: 'Feature', - geometry: { - type: 'Point', - coordinates: [lngLat.lng, lngLat.lat] - }, - properties: { - radius: 0 - } - }] - } - }); +// if (!map.getSource(sourceId)) { +// map.addSource(sourceId, { +// type: 'geojson', +// data: { +// type: 'FeatureCollection', +// features: [{ +// type: 'Feature', +// geometry: { +// type: 'Point', +// coordinates: [lngLat.lng, lngLat.lat] +// }, +// properties: { +// radius: 0 +// } +// }] +// } +// }); - for (let i = 0; i < count; i++) { - const layerId = `${sourceId}-layer-${i}`; - const delay = i * (duration / count); +// for (let i = 0; i < count; i++) { +// const layerId = `${sourceId}-layer-${i}`; +// const delay = i * (duration / count); - map.addLayer({ - id: layerId, - type: 'circle', - source: sourceId, - paint: { - 'circle-radius': ['interpolate', ['linear'], ['get', 'radius'], 0, 0, 100, maxRadius], - 'circle-color': 'transparent', - 'circle-opacity': ['interpolate', ['linear'], ['get', 'radius'], - 0, showCenter ? 0.15 : 0, // Lower opacity - 100, 0 - ], - 'circle-stroke-width': 1.5, // Thinner stroke - 'circle-stroke-color': color - } - }); +// map.addLayer({ +// id: layerId, +// type: 'circle', +// source: sourceId, +// paint: { +// 'circle-radius': ['interpolate', ['linear'], ['get', 'radius'], 0, 0, 100, maxRadius], +// 'circle-color': 'transparent', +// 'circle-opacity': ['interpolate', ['linear'], ['get', 'radius'], +// 0, showCenter ? 0.15 : 0, // Lower opacity +// 100, 0 +// ], +// 'circle-stroke-width': 1.5, // Thinner stroke +// 'circle-stroke-color': color +// } +// }); - this.animateWaveCircle(map, sourceId, layerId, duration, delay); - } - } - } +// this.animateWaveCircle(map, sourceId, layerId, duration, delay); +// } +// } +// } - private animateWaveCircle( - map: mapboxgl.Map, - sourceId: string, - layerId: string, - duration: number, - delay: number - ): void { - let start: number | null = null; - let animationId: number; +// private animateWaveCircle( +// map: mapboxgl.Map, +// sourceId: string, +// layerId: string, +// duration: number, +// delay: number +// ): void { +// let start: number | null = null; +// let animationId: number; - const animate = (timestamp: number) => { - if (!start) { - start = timestamp + delay; - } +// const animate = (timestamp: number) => { +// if (!start) { +// start = timestamp + delay; +// } - const progress = Math.max(0, timestamp - start); - const progressPercent = Math.min(progress / duration, 1); +// const progress = Math.max(0, timestamp - start); +// const progressPercent = Math.min(progress / duration, 1); - if (map.getSource(sourceId)) { - (map.getSource(sourceId) as mapboxgl.GeoJSONSource).setData({ - type: 'FeatureCollection', - features: [{ - type: 'Feature', - geometry: { - type: 'Point', - coordinates: (map.getSource(sourceId) as any)._data.features[0].geometry.coordinates - }, - properties: { - radius: progressPercent * 100 - } - }] - }); - } +// if (map.getSource(sourceId)) { +// (map.getSource(sourceId) as mapboxgl.GeoJSONSource).setData({ +// type: 'FeatureCollection', +// features: [{ +// type: 'Feature', +// geometry: { +// type: 'Point', +// coordinates: (map.getSource(sourceId) as any)._data.features[0].geometry.coordinates +// }, +// properties: { +// radius: progressPercent * 100 +// } +// }] +// }); +// } - if (progressPercent < 1) { - animationId = requestAnimationFrame(animate); - } else if (map.getLayer(layerId)) { - // Restart the animation for continuous effect - start = null; - animationId = requestAnimationFrame(animate); - } - }; +// if (progressPercent < 1) { +// animationId = requestAnimationFrame(animate); +// } else if (map.getLayer(layerId)) { +// // Restart the animation for continuous effect +// start = null; +// animationId = requestAnimationFrame(animate); +// } +// }; - // Start the animation after delay - setTimeout(() => { - animationId = requestAnimationFrame(animate); - }, delay); +// // Start the animation after delay +// setTimeout(() => { +// animationId = requestAnimationFrame(animate); +// }, delay); - // Clean up on popup close - this.once('close', () => { - cancelAnimationFrame(animationId); - if (map.getLayer(layerId)) { - map.removeLayer(layerId); - } - if (map.getSource(sourceId) && !map.getLayer(layerId)) { - map.removeSource(sourceId); - } - }); - } -} +// // Clean up on popup close +// this.once('close', () => { +// cancelAnimationFrame(animationId); +// if (map.getLayer(layerId)) { +// map.removeLayer(layerId); +// } +// if (map.getSource(sourceId) && !map.getLayer(layerId)) { +// map.removeSource(sourceId); +// } +// }); +// } +// } -// Add styles to document when in browser environment -if (typeof document !== 'undefined') { - // Add styles only if they don't exist yet - if (!document.getElementById('custom-animated-popup-styles')) { - const style = document.createElement('style'); - style.id = 'custom-animated-popup-styles'; - style.textContent = ` - .custom-animated-popup { - will-change: transform, opacity; - } - .custom-animated-popup .mapboxgl-popup-content { - overflow: hidden; - } +// // Add styles to document when in browser environment +// if (typeof document !== 'undefined') { +// // Add styles only if they don't exist yet +// if (!document.getElementById('custom-animated-popup-styles')) { +// const style = document.createElement('style'); +// style.id = 'custom-animated-popup-styles'; +// style.textContent = ` +// .custom-animated-popup { +// will-change: transform, opacity; +// } +// .custom-animated-popup .mapboxgl-popup-content { +// overflow: hidden; +// } - /* Marker styles with wave circles */ - .marker-gempa { - position: relative; - width: 30px; - height: 30px; - cursor: pointer; - } +// /* Marker styles with wave circles */ +// .marker-gempa { +// position: relative; +// width: 30px; +// height: 30px; +// cursor: pointer; +// } - .circles { - position: relative; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - } +// .circles { +// position: relative; +// width: 100%; +// height: 100%; +// display: flex; +// align-items: center; +// justify-content: center; +// } - .circle1, .circle2, .circle3 { - position: absolute; - border-radius: 50%; - border: 2px solid red; - width: 100%; - height: 100%; - opacity: 0; - animation: pulse 2s infinite; - } +// .circle1, .circle2, .circle3 { +// position: absolute; +// border-radius: 50%; +// border: 2px solid red; +// width: 100%; +// height: 100%; +// opacity: 0; +// animation: pulse 2s infinite; +// } - .circle2 { - animation-delay: 0.5s; - } +// .circle2 { +// animation-delay: 0.5s; +// } - .circle3 { - animation-delay: 1s; - } +// .circle3 { +// animation-delay: 1s; +// } - @keyframes pulse { - 0% { - transform: scale(0.5); - opacity: 0; - } - 50% { - opacity: 0.8; - } - 100% { - transform: scale(1.5); - opacity: 0; - } - } +// @keyframes pulse { +// 0% { +// transform: scale(0.5); +// opacity: 0; +// } +// 50% { +// opacity: 0.8; +// } +// 100% { +// transform: scale(1.5); +// opacity: 0; +// } +// } - .blink { - animation: blink 1s infinite; - color: red; - font-size: 20px; - } +// .blink { +// animation: blink 1s infinite; +// color: red; +// font-size: 20px; +// } - @keyframes blink { - 0% { opacity: 1; } - 50% { opacity: 0.3; } - 100% { opacity: 1; } - } - `; - document.head.appendChild(style); - } -} +// @keyframes blink { +// 0% { opacity: 1; } +// 50% { opacity: 0.3; } +// 100% { opacity: 1; } +// } +// `; +// document.head.appendChild(style); +// } +// }