558 lines
21 KiB
TypeScript
558 lines
21 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 IncidentPopup from "../pop-up/incident-popup"
|
|
|
|
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[]>([])
|
|
|
|
// 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 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(() => {
|
|
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: {
|
|
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: [
|
|
parseFloat(String(unit.longitude)) || 0,
|
|
parseFloat(String(unit.latitude)) || 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",
|
|
distance_to_unit: incident.locations.distance_to_unit || "Unknown",
|
|
},
|
|
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])
|
|
|
|
// 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
|
|
|
|
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;
|
|
}
|
|
|
|
// Fly to the unit location
|
|
map.flyTo({
|
|
center: [unit.longitude || 0, unit.latitude || 0],
|
|
zoom: 14,
|
|
pitch: 45,
|
|
bearing: 0,
|
|
duration: 2000,
|
|
})
|
|
|
|
// 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)
|
|
},
|
|
[],
|
|
)
|
|
|
|
// 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
|
|
|
|
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: 15,
|
|
pitch: 45,
|
|
bearing: 0,
|
|
duration: 2000,
|
|
})
|
|
|
|
// 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: properties.distance_to_unit,
|
|
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)
|
|
|
|
// 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,
|
|
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 to confirm map layers
|
|
console.log("Available map layers:", map.getStyle().layers?.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 = ""
|
|
}
|
|
|
|
// Add click event for units-points layer
|
|
if (map.getLayer("units-points")) {
|
|
map.off("click", "units-points", unitClickHandler)
|
|
map.on("click", "units-points", unitClickHandler)
|
|
|
|
// 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)
|
|
|
|
// Change cursor on hover
|
|
map.on("mouseenter", "incidents-points", handleMouseEnter)
|
|
map.on("mouseleave", "incidents-points", handleMouseLeave)
|
|
}
|
|
|
|
return () => {
|
|
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(() => {
|
|
setSelectedUnit(null)
|
|
setSelectedIncident(null)
|
|
setSelectedEntityId(undefined)
|
|
setSelectedDistrictId(undefined)
|
|
|
|
if (map && map.getLayer("units-connection-lines")) {
|
|
map.setFilter("units-connection-lines", ["has", "unit_id"])
|
|
}
|
|
}, [map])
|
|
|
|
// Clean up on unmount or when visibility changes
|
|
useEffect(() => {
|
|
if (!visible) {
|
|
handleClosePopup()
|
|
}
|
|
}, [visible, handleClosePopup])
|
|
|
|
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,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Custom Incident Popup */}
|
|
{selectedIncident && (
|
|
<IncidentPopup
|
|
longitude={selectedIncident.longitude}
|
|
latitude={selectedIncident.latitude}
|
|
onClose={handleClosePopup}
|
|
incident={selectedIncident}
|
|
/>
|
|
)}
|
|
</>
|
|
)
|
|
}
|