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

452 lines
17 KiB
TypeScript

"use client"
import { useEffect, useMemo, useRef, useState } from 'react'
import { Layer, Source } from "react-map-gl/mapbox"
import { ICrimes, IDistanceResult } from "@/app/_utils/types/crimes"
import { IUnits } from "@/app/_utils/types/units"
import mapboxgl from 'mapbox-gl'
import { useQuery } from '@tanstack/react-query'
import { generateCategoryColorMap, getCategoryColor } from '@/app/_utils/colors'
import UnitPopup from '../pop-up/unit-popup'
import IncidentPopup from '../pop-up/incident-popup'
import { calculateDistances } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action'
interface UnitsLayerProps {
crimes: ICrimes[]
units?: IUnits[]
filterCategory: string | "all"
visible?: boolean
map?: mapboxgl.Map | null
}
// Custom hook for fetching distance data
const useDistanceData = (entityId?: string, isUnit: boolean = false, districtId?: string) => {
// Skip the query when no entity is selected
return useQuery({
queryKey: ['distance-incidents', entityId, isUnit, districtId],
queryFn: async () => {
if (!entityId) return [];
const unitId = isUnit ? entityId : undefined;
const result = await calculateDistances(unitId, districtId);
return result;
},
enabled: !!entityId, // Only run query when there's an entityId
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
gcTime: 10 * 60 * 1000, // Keep cache for 10 minutes
});
};
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>()
// Use react-query for distance data
const { data: distances = [], isLoading: isLoadingDistances } = useDistanceData(
selectedEntityId,
isUnitSelected,
selectedDistrictId
);
// 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 => ({
type: "Feature" as const,
properties: {
id: unit.code_unit,
name: unit.name,
address: unit.address,
phone: unit.phone,
type: unit.type,
district: unit.districts?.name || "",
district_id: unit.district_id,
},
geometry: {
type: "Point" as const,
coordinates: [unit.longitude || 0, unit.latitude || 0]
}
})).filter(feature =>
feature.geometry.coordinates[0] !== 0 &&
feature.geometry.coordinates[1] !== 0
)
}
}, [unitsData])
// Process incident data to GeoJSON format
const incidentsGeoJSON = useMemo(() => {
const features: any[] = [];
crimes.forEach(crime => {
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;
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',
},
geometry: {
type: "Point" as const,
coordinates: [incident.locations.longitude, incident.locations.latitude]
}
});
});
});
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])
useEffect(() => {
if (!map || !visible) return
const handleUnitClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => {
if (!e.features || e.features.length === 0) return
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) return;
// 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
])
}
}
const handleIncidentClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => {
if (!e.features || e.features.length === 0) return;
const feature = e.features[0];
const properties = feature.properties;
if (!properties) return;
// 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,
};
// Set the selected incident and query parameters
setSelectedIncident({
...incident,
latitude: feature.geometry.type === 'Point' ?
(feature.geometry as any).coordinates[1] : 0,
longitude: feature.geometry.type === 'Point' ?
(feature.geometry as any).coordinates[0] : 0
});
setSelectedUnit(null);
setSelectedEntityId(properties.id);
setIsUnitSelected(false);
setSelectedDistrictId(properties.district_id);
// Highlight the connected lines for this incident
if (map.getLayer('units-connection-lines')) {
map.setFilter('units-connection-lines', [
'==',
['get', 'incident_id'],
properties.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 = ''
}
// Add click event for units-points layer
if (map.getLayer('units-points')) {
map.on('click', 'units-points', handleUnitClick)
// 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.on('click', 'incidents-points', handleIncidentClick)
// Change cursor on hover
map.on('mouseenter', 'incidents-points', handleMouseEnter)
map.on('mouseleave', 'incidents-points', handleMouseLeave)
}
return () => {
if (map.getLayer('units-points')) {
map.off('click', 'units-points', handleUnitClick)
map.off('mouseenter', 'units-points', handleMouseEnter)
map.off('mouseleave', 'units-points', handleMouseLeave)
}
if (map.getLayer('incidents-points')) {
map.off('click', 'incidents-points', handleIncidentClick)
map.off('mouseenter', 'incidents-points', handleMouseEnter)
map.off('mouseleave', 'incidents-points', handleMouseLeave)
}
}
}, [map, visible, unitsData])
// Reset map filters when popup is closed
const handleClosePopup = () => {
setSelectedUnit(null);
setSelectedIncident(null);
setSelectedEntityId(undefined);
setSelectedDistrictId(undefined);
if (map && map.getLayer('units-connection-lines')) {
map.setFilter('units-connection-lines', ['has', 'unit_id']);
}
};
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.districts?.name,
district_id: selectedUnit.district_id
}}
distances={distances}
isLoadingDistances={isLoadingDistances}
/>
)}
{/* Custom Incident Popup */}
{selectedIncident && (
<IncidentPopup
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={handleClosePopup}
incident={selectedIncident}
distances={distances}
isLoadingDistances={isLoadingDistances}
/>
)}
</>
)
}