275 lines
10 KiB
TypeScript
275 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { Layer, Source } from "react-map-gl/mapbox"
|
|
import { ICrimes } from "@/app/_utils/types/crimes"
|
|
import { IUnits } from "@/app/_utils/types/units"
|
|
import mapboxgl from 'mapbox-gl'
|
|
import { generateCategoryColorMap, getCategoryColor } from '@/app/_utils/colors'
|
|
|
|
interface UnitsLayerProps {
|
|
crimes: ICrimes[]
|
|
units?: IUnits[]
|
|
filterCategory: string | "all"
|
|
visible?: boolean
|
|
map?: mapboxgl.Map | null
|
|
}
|
|
|
|
export default function UnitsLayer({
|
|
crimes,
|
|
units = [],
|
|
filterCategory,
|
|
visible = false,
|
|
map
|
|
}: UnitsLayerProps) {
|
|
const [loadedUnits, setLoadedUnits] = useState<IUnits[]>([])
|
|
const loadedUnitsRef = useRef<IUnits[]>([])
|
|
|
|
// 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])
|
|
|
|
// 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])
|
|
|
|
// Map click handler code and the rest remains the same...
|
|
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
|
|
|
|
// Create a popup for the unit
|
|
const popup = new mapboxgl.Popup()
|
|
.setLngLat(feature.geometry.type === 'Point' ?
|
|
(feature.geometry as any).coordinates as [number, number] :
|
|
[0, 0]) // Fallback coordinates if not a Point geometry
|
|
.setHTML(`
|
|
<div class="p-2">
|
|
<h3 class="font-bold text-base">${properties.name}</h3>
|
|
<p class="text-sm">${properties.type}</p>
|
|
<p class="text-sm">${properties.address || 'No address provided'}</p>
|
|
<p class="text-xs mt-2">Staff: ${properties.staff_count || 'N/A'}</p>
|
|
<p class="text-xs">Phone: ${properties.phone || 'N/A'}</p>
|
|
<p class="text-xs">District: ${properties.district || 'N/A'}</p>
|
|
</div>
|
|
`)
|
|
.addTo(map)
|
|
|
|
// Highlight the connected lines for this unit
|
|
if (map.getLayer('units-connection-lines')) {
|
|
map.setFilter('units-connection-lines', [
|
|
'==',
|
|
['get', 'unit_id'],
|
|
properties.id
|
|
])
|
|
}
|
|
|
|
// When popup closes, reset the lines filter
|
|
popup.on('close', () => {
|
|
if (map.getLayer('units-connection-lines')) {
|
|
map.setFilter('units-connection-lines', ['has', 'unit_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)
|
|
}
|
|
|
|
return () => {
|
|
if (map.getLayer('units-points')) {
|
|
map.off('click', 'units-points', handleUnitClick)
|
|
map.off('mouseenter', 'units-points', handleMouseEnter)
|
|
map.off('mouseleave', 'units-points', handleMouseLeave)
|
|
}
|
|
}
|
|
}, [map, 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>
|
|
|
|
{/* 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': 1.5,
|
|
'line-opacity': 0.7,
|
|
'line-dasharray': [1, 2] // Dashed line
|
|
}}
|
|
/>
|
|
</Source>
|
|
</>
|
|
)
|
|
}
|