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

718 lines
28 KiB
TypeScript

"use client"
import { useEffect, useMemo, useRef, useState, useCallback } from "react"
import { Layer, Source } from "react-map-gl/mapbox"
import type { ICrimes } from "@/app/_utils/types/crimes"
import type { IUnits } from "@/app/_utils/types/units"
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 {
crimes: ICrimes[]
units?: IUnits[]
filterCategory: string | "all"
visible?: boolean
map?: mapboxgl.Map | null
}
interface IDistrictIncidents {
incident_id: string
category_name: string
incident_description: string
distance_meters: number
timestamp: Date
}
// New interface to better type the incident properties
interface IncidentProperties {
id: string
description: string
category: string
date: string
district: string
district_id: string
categoryColor: string
distance_to_unit: number | "Unknown"
longitude: number
latitude: number
}
export default function UnitsLayer({ crimes, units = [], filterCategory, visible = false, map }: UnitsLayerProps) {
const [loadedUnits, setLoadedUnits] = useState<IUnits[]>([])
const loadedUnitsRef = useRef<IUnits[]>([])
// For popups
const [selectedUnit, setSelectedUnit] = useState<IUnits | null>(null)
const [selectedIncident, setSelectedIncident] = useState<any | null>(null)
const [selectedEntityId, setSelectedEntityId] = useState<string | undefined>()
const [isUnitSelected, setIsUnitSelected] = useState<boolean>(false)
const [selectedDistrictId, setSelectedDistrictId] = useState<string | undefined>()
const [unitIncident, setUnitIncident] = useState<IDistrictIncidents[]>([])
const [isLoading, setIsLoading] = useState<boolean>(false)
const [incidentCoords, setIncidentCoords] = useState<{ lat: number, lon: number } | null>(null)
const { data: nearestUnits, isLoading: isLoadingNearestUnits } = useGetNearestUnitsQuery(
incidentCoords?.lat ?? 0,
incidentCoords?.lon ?? 0,
5
)
// Add a ref to store pre-processed incidents by district for optimization
const districtIncidentsCache = useRef<Map<string, IDistrictIncidents[]>>(new Map());
// Use either provided units or loaded units
const unitsData = useMemo(() => {
return units.length > 0 ? units : loadedUnits || []
}, [units, loadedUnits])
// Extract all unique crime categories for color generation
const uniqueCategories = useMemo(() => {
const categories = new Set<string>()
crimes.forEach((crime) => {
crime.crime_incidents.forEach((incident) => {
if (incident.crime_categories?.name) {
categories.add(incident.crime_categories.name)
}
})
})
return Array.from(categories)
}, [crimes])
// Generate color map for all categories
const categoryColorMap = useMemo(() => {
return generateCategoryColorMap(uniqueCategories)
}, [uniqueCategories])
// Process units data to GeoJSON format
const unitsGeoJSON = useMemo(() => {
return {
type: "FeatureCollection" as const,
features: unitsData.map((unit) => {
return {
type: "Feature" as const,
properties: {
id: unit.code_unit,
name: unit.name,
address: unit.address,
phone: unit.phone,
type: unit.type,
district: unit.district_name || "",
district_id: unit.district_id,
},
geometry: {
type: "Point" as const,
coordinates: [
Number.parseFloat(String(unit.longitude)) || 0,
Number.parseFloat(String(unit.latitude)) || 0,
],
},
}
}),
}
}, [unitsData])
// Process incident data to GeoJSON format
const incidentsGeoJSON = useMemo(() => {
const features: any[] = []
// Also build the district incidents cache while processing crime data
const newDistrictIncidentsCache = new Map<string, IDistrictIncidents[]>();
crimes.forEach((crime) => {
// Initialize the array for this district if it doesn't exist yet
if (!newDistrictIncidentsCache.has(crime.district_id)) {
newDistrictIncidentsCache.set(crime.district_id, []);
}
crime.crime_incidents.forEach((incident) => {
// Skip incidents without location data or filtered by category
if (
!incident.locations?.latitude ||
!incident.locations?.longitude ||
(filterCategory !== "all" && incident.crime_categories.name !== filterCategory)
)
return
// Ensure distance_to_unit is properly initialized
const distance = incident.locations.distance_to_unit !== undefined
? incident.locations.distance_to_unit
: "Unknown";
// Add to district incidents cache for quicker lookup
if (incident.locations.distance_to_unit !== undefined) {
newDistrictIncidentsCache.get(crime.district_id)?.push({
incident_id: incident.id,
category_name: incident.crime_categories.name,
incident_description: incident.description || "No description",
distance_meters: incident.locations.distance_to_unit!,
timestamp: incident.timestamp,
});
}
features.push({
type: "Feature" as const,
properties: {
id: incident.id,
description: incident.description || "No description",
category: incident.crime_categories.name,
date: incident.timestamp,
district: crime.districts.name,
district_id: crime.district_id,
categoryColor: categoryColorMap[incident.crime_categories.name] || "#22c55e",
distance_to_unit: distance,
},
geometry: {
type: "Point" as const,
coordinates: [incident.locations.longitude, incident.locations.latitude],
},
})
})
})
// Update the cache ref with our new data
districtIncidentsCache.current = newDistrictIncidentsCache;
return {
type: "FeatureCollection" as const,
features,
}
}, [crimes, filterCategory, categoryColorMap])
// Create lines between units and incidents within their districts
const connectionLinesGeoJSON = useMemo(() => {
if (!unitsData.length || !crimes.length)
return {
type: "FeatureCollection" as const,
features: [],
}
// Map district IDs to their units
const districtUnitsMap = new Map<string, IUnits[]>()
unitsData.forEach((unit) => {
if (!unit.district_id || !unit.longitude || !unit.latitude) return
if (!districtUnitsMap.has(unit.district_id)) {
districtUnitsMap.set(unit.district_id, [])
}
districtUnitsMap.get(unit.district_id)!.push(unit)
})
// Create lines from units to incidents in their district
const lineFeatures: any[] = []
crimes.forEach((crime) => {
// Get all units in this district
const districtUnits = districtUnitsMap.get(crime.district_id) || []
if (!districtUnits.length) return
// For each incident in this district
crime.crime_incidents.forEach((incident) => {
// Skip incidents without location data or filtered by category
if (
!incident.locations?.latitude ||
!incident.locations?.longitude ||
(filterCategory !== "all" && incident.crime_categories.name !== filterCategory)
)
return
// Create a line from each unit in this district to this incident
districtUnits.forEach((unit) => {
if (!unit.longitude || !unit.latitude) return
lineFeatures.push({
type: "Feature" as const,
properties: {
unit_id: unit.code_unit,
unit_name: unit.name,
incident_id: incident.id,
district_id: crime.district_id,
district_name: crime.districts.name,
category: incident.crime_categories.name,
lineColor: categoryColorMap[incident.crime_categories.name] || "#22c55e",
},
geometry: {
type: "LineString" as const,
coordinates: [
[unit.longitude, unit.latitude],
[incident.locations.longitude, incident.locations.latitude],
],
},
})
})
})
})
return {
type: "FeatureCollection" as const,
features: lineFeatures,
}
}, [unitsData, crimes, filterCategory, categoryColorMap])
// Handle unit click
const handleUnitClick = useCallback(
(
map: mapboxgl.Map,
unitsData: IUnits[],
setSelectedUnit: (unit: IUnits | null) => void,
setSelectedIncident: (incident: any | null) => void,
setSelectedEntityId: (id: string | undefined) => void,
setIsUnitSelected: (isSelected: boolean) => void,
setSelectedDistrictId: (id: string | undefined) => void,
) =>
(e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => {
if (!e.features || e.features.length === 0) return
// Stop event propagation to prevent district layer from handling this click
e.originalEvent.stopPropagation()
e.preventDefault()
const feature = e.features[0]
const properties = feature.properties
if (!properties) return
// 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
}
setIsLoading(true)
// Early exit if district_id is not available
if (!unit.district_id) {
console.log("Unit has no district ID")
setUnitIncident([])
setIsLoading(false)
return
}
// Use the pre-processed district incidents from cache
let districtIncidents = districtIncidentsCache.current.get(unit.district_id) || [];
// If we don't have them in cache for some reason, compute them now
if (districtIncidents.length === 0) {
const tempIncidents: IDistrictIncidents[] = [];
// Only process crimes for this specific district
crimes
.filter(crime => crime.district_id === unit.district_id)
.forEach(crime => {
crime.crime_incidents.forEach(incident => {
if (incident.locations && typeof incident.locations.distance_to_unit !== "undefined") {
tempIncidents.push({
incident_id: incident.id,
category_name: incident.crime_categories.name,
incident_description: incident.description || "No description",
distance_meters: incident.locations.distance_to_unit!,
timestamp: incident.timestamp,
});
}
});
});
districtIncidents = tempIncidents;
}
// Sort by distance (closest first)
districtIncidents.sort((a, b) => a.distance_meters - b.distance_meters);
// Update the state with the distance results
setUnitIncident(districtIncidents)
setIsLoading(false)
// Fly to the unit location
map.flyTo({
center: [unit.longitude || 0, unit.latitude || 0],
zoom: 12.5,
pitch: 45,
bearing: BASE_BEARING,
duration: BASE_DURATION,
})
// Set the selected unit and query parameters
setSelectedUnit(unit)
setSelectedIncident(null) // Clear any selected incident
setSelectedEntityId(properties.id)
setIsUnitSelected(true)
setSelectedDistrictId(properties.district_id)
// Highlight the connected lines for this unit
if (map.getLayer("units-connection-lines")) {
map.setFilter("units-connection-lines", ["==", ["get", "unit_id"], properties.id])
}
// Dispatch a custom event for other components to react to
const customEvent = new CustomEvent("unit_click", {
detail: {
unitId: properties.id,
districtId: properties.district_id,
name: properties.name,
longitude: feature.geometry.type === "Point" ? (feature.geometry as any).coordinates[0] : 0,
latitude: feature.geometry.type === "Point" ? (feature.geometry as any).coordinates[1] : 0,
},
bubbles: true,
})
map.getCanvas().dispatchEvent(customEvent)
document.dispatchEvent(customEvent)
},
[crimes], // Add crimes as a dependency
)
// Handle incident click
const handleIncidentClick = useCallback(
(
map: mapboxgl.Map,
setSelectedIncident: (incident: any | null) => void,
setSelectedUnit: (unit: IUnits | null) => void,
setSelectedEntityId: (id: string | undefined) => void,
setIsUnitSelected: (isSelected: boolean) => void,
setSelectedDistrictId: (id: string | undefined) => void,
) =>
(e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => {
if (!e.features || e.features.length === 0) return
// Stop event propagation
e.originalEvent.stopPropagation()
e.preventDefault()
const feature = e.features[0]
const properties = feature.properties
if (!properties) return
// Get coordinates
const longitude = feature.geometry.type === "Point" ? (feature.geometry as any).coordinates[0] : 0
const latitude = feature.geometry.type === "Point" ? (feature.geometry as any).coordinates[1] : 0
// Fly to the incident location
map.flyTo({
center: [longitude, latitude],
zoom: 16,
pitch: 45,
bearing: BASE_BEARING,
duration: BASE_DURATION,
})
// Ensure distance_to_unit has a value - use the value from GeoJSON properties directly
// This ensures we use the same data that was calculated for the GeoJSON
let distanceToUnit = properties.distance_to_unit;
// Create incident object from properties
const incident = {
id: properties.id,
category: properties.category,
description: properties.description,
date: properties.date,
district: properties.district,
district_id: properties.district_id,
distance_to_unit: distanceToUnit,
longitude,
latitude,
}
// 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
const customEvent = new CustomEvent("incident_click", {
detail: {
id: properties.id,
district: properties.district,
category: properties.category,
description: properties.description,
distance_to_unit: distanceToUnit,
longitude,
latitude,
},
bubbles: true,
})
map.getCanvas().dispatchEvent(customEvent)
document.dispatchEvent(customEvent)
},
[],
)
const unitClickHandler = useMemo(
() =>
handleUnitClick(
map as mapboxgl.Map,
unitsData,
setSelectedUnit,
setSelectedIncident,
setSelectedEntityId,
setIsUnitSelected,
setSelectedDistrictId,
),
[
map,
unitsData,
setSelectedUnit,
setSelectedIncident,
setSelectedEntityId,
setIsUnitSelected,
setSelectedDistrictId,
],
)
const incidentClickHandler = useMemo(
() =>
handleIncidentClick(
map as mapboxgl.Map,
setSelectedIncident,
setSelectedUnit,
setSelectedEntityId,
setIsUnitSelected,
setSelectedDistrictId,
),
[map, setSelectedIncident, setSelectedUnit, setSelectedEntityId, setIsUnitSelected, setSelectedDistrictId],
)
// Set up event handlers
useEffect(() => {
if (!map || !visible) return
// 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
const handleMouseEnter = () => {
map.getCanvas().style.cursor = "pointer"
}
const handleMouseLeave = () => {
map.getCanvas().style.cursor = ""
}
// 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")
}
// 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")
}
}
// Setup handlers langsung
setupHandlers()
// 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)
map.off("mouseenter", "units-points", handleMouseEnter)
map.off("mouseleave", "units-points", handleMouseLeave)
}
if (map.getLayer("incidents-points")) {
map.off("click", "incidents-points", incidentClickHandler)
map.off("mouseenter", "incidents-points", handleMouseEnter)
map.off("mouseleave", "incidents-points", handleMouseLeave)
}
}
}
}, [map, visible, unitClickHandler, incidentClickHandler])
// Reset map filters when popup is closed
const handleClosePopup = useCallback(() => {
// console.log("Closing popup, clearing selected states")
setSelectedUnit(null)
setSelectedIncident(null)
setSelectedEntityId(undefined)
setSelectedDistrictId(undefined)
setUnitIncident([])
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])
// Clean up on unmount or when visibility changes
useEffect(() => {
if (!visible) {
handleClosePopup()
}
}, [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 (
<>
{/* Units Points */}
<Source id="units-source" type="geojson" data={unitsGeoJSON}>
<Layer
id="units-points"
type="circle"
paint={{
"circle-radius": 8,
"circle-color": "#1e40af", // Deep blue for police units
"circle-stroke-width": 2,
"circle-stroke-color": "#ffffff",
"circle-opacity": 0.8,
}}
/>
{/* Units Symbols */}
<Layer
id="units-symbols"
type="symbol"
layout={{
"text-field": ["get", "name"],
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
"text-size": 12,
"text-offset": [0, -2],
"text-anchor": "bottom",
"text-allow-overlap": false,
"text-ignore-placement": false,
}}
paint={{
"text-color": "#ffffff",
"text-halo-color": "#000000",
"text-halo-width": 1,
}}
/>
</Source>
{/* Incidents Points */}
<Source id="incidents-source" type="geojson" data={incidentsGeoJSON}>
<Layer
id="incidents-points"
type="circle"
paint={{
"circle-radius": 6,
// Use the pre-computed color stored in the properties
"circle-color": ["get", "categoryColor"],
"circle-stroke-width": 1,
"circle-stroke-color": "#ffffff",
"circle-opacity": 0.8,
}}
/>
</Source>
{/* Connection Lines */}
<Source id="units-lines-source" type="geojson" data={connectionLinesGeoJSON}>
<Layer
id="units-connection-lines"
type="line"
paint={{
// Use the pre-computed color stored in the properties
"line-color": ["get", "lineColor"],
"line-width": 3,
"line-opacity": 0.9,
"line-blur": 0.5,
"line-dasharray": [3, 1],
}}
/>
</Source>
{/* Custom Unit Popup */}
{selectedUnit && (
<UnitPopup
longitude={selectedUnit.longitude || 0}
latitude={selectedUnit.latitude || 0}
onClose={handleClosePopup}
unit={{
id: selectedUnit.code_unit,
name: selectedUnit.name,
type: selectedUnit.type,
address: selectedUnit.address || "No address",
phone: selectedUnit.phone || "No phone",
district: selectedUnit.district_name || "No district",
district_id: selectedUnit.district_id,
}}
incidents={unitIncident}
isLoadingIncidents={isLoading}
/>
)}
{/* Custom Incident Popup */}
{selectedIncident && (
<IncidentPopup
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={handleClosePopup}
incident={selectedIncident}
nearestUnit={nearestUnits}
isLoadingNearestUnit={isLoadingNearestUnits}
/>
)}
</>
)
}