452 lines
17 KiB
TypeScript
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}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|