298 lines
10 KiB
TypeScript
298 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState, useCallback } from "react"
|
|
import { Layer, Source } from "react-map-gl/mapbox"
|
|
import type { ICrimes } from "@/app/_utils/types/crimes"
|
|
import type mapboxgl from "mapbox-gl"
|
|
import { format } from "date-fns"
|
|
import { calculateAverageTimeOfDay } from "@/app/_utils/time"
|
|
import TimelinePopup from "../pop-up/timeline-popup"
|
|
import TimeZonesDisplay from "../timezone"
|
|
|
|
interface TimelineLayerProps {
|
|
crimes: ICrimes[]
|
|
year: string
|
|
month: string
|
|
filterCategory: string | "all"
|
|
visible?: boolean
|
|
map?: mapboxgl.Map | null
|
|
useAllData?: boolean
|
|
}
|
|
|
|
export default function TimelineLayer({
|
|
crimes,
|
|
year,
|
|
month,
|
|
filterCategory,
|
|
visible = false,
|
|
map,
|
|
useAllData = false,
|
|
}: TimelineLayerProps) {
|
|
// State for selected district and popup
|
|
const [selectedDistrict, setSelectedDistrict] = useState<any | null>(null)
|
|
const [showTimeZones, setShowTimeZones] = useState<boolean>(true)
|
|
|
|
// Process district data to extract average incident times
|
|
const districtTimeData = useMemo(() => {
|
|
// Group incidents by district
|
|
const districtGroups = new Map<
|
|
string,
|
|
{
|
|
districtId: string
|
|
districtName: string
|
|
incidents: Array<{ timestamp: Date; category: string }>
|
|
center: [number, number]
|
|
}
|
|
>()
|
|
|
|
crimes.forEach((crime) => {
|
|
if (!crime.districts || !crime.district_id) return
|
|
|
|
// Initialize district group if not exists
|
|
if (!districtGroups.has(crime.district_id)) {
|
|
// Find a central location for the district from any incident
|
|
const centerIncident = crime.crime_incidents.find((inc) => inc.locations?.latitude && inc.locations?.longitude)
|
|
|
|
const center: [number, number] = centerIncident
|
|
? [centerIncident.locations.longitude, centerIncident.locations.latitude]
|
|
: [0, 0]
|
|
|
|
districtGroups.set(crime.district_id, {
|
|
districtId: crime.district_id,
|
|
districtName: crime.districts.name,
|
|
incidents: [],
|
|
center,
|
|
})
|
|
}
|
|
|
|
// Filter incidents appropriately before adding
|
|
crime.crime_incidents.forEach((incident) => {
|
|
// Skip invalid incidents
|
|
if (!incident.timestamp) return
|
|
if (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) return
|
|
|
|
// Add to appropriate district group
|
|
const group = districtGroups.get(crime.district_id)
|
|
if (group) {
|
|
group.incidents.push({
|
|
timestamp: new Date(incident.timestamp),
|
|
category: incident.crime_categories.name,
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
// Calculate average time for each district
|
|
const result = Array.from(districtGroups.values())
|
|
.filter((group) => group.incidents.length > 0 && group.center[0] !== 0)
|
|
.map((group) => {
|
|
const avgTimeInfo = calculateAverageTimeOfDay(group.incidents.map((inc) => inc.timestamp))
|
|
|
|
return {
|
|
id: group.districtId,
|
|
name: group.districtName,
|
|
center: group.center,
|
|
avgHour: avgTimeInfo.hour,
|
|
avgMinute: avgTimeInfo.minute,
|
|
formattedTime: avgTimeInfo.formattedTime,
|
|
timeDescription: avgTimeInfo.description,
|
|
totalIncidents: group.incidents.length,
|
|
timeOfDay: avgTimeInfo.timeOfDay,
|
|
earliestTime: format(avgTimeInfo.earliest, "p"),
|
|
latestTime: format(avgTimeInfo.latest, "p"),
|
|
mostFrequentHour: avgTimeInfo.mostFrequentHour,
|
|
categoryCounts: group.incidents.reduce(
|
|
(acc, inc) => {
|
|
acc[inc.category] = (acc[inc.category] || 0) + 1
|
|
return acc
|
|
},
|
|
{} as Record<string, number>,
|
|
),
|
|
}
|
|
})
|
|
|
|
return result
|
|
}, [crimes, filterCategory, year, month])
|
|
|
|
// Convert processed data to GeoJSON for display
|
|
const timelineGeoJSON = useMemo(() => {
|
|
return {
|
|
type: "FeatureCollection" as const,
|
|
features: districtTimeData.map((district) => ({
|
|
type: "Feature" as const,
|
|
properties: {
|
|
id: district.id,
|
|
name: district.name,
|
|
avgTime: district.formattedTime,
|
|
timeDescription: district.timeDescription,
|
|
totalIncidents: district.totalIncidents,
|
|
timeOfDay: district.timeOfDay,
|
|
hour: district.avgHour,
|
|
minute: district.avgMinute,
|
|
},
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: district.center,
|
|
},
|
|
})),
|
|
}
|
|
}, [districtTimeData])
|
|
|
|
// Handle marker click
|
|
const handleMarkerClick = useCallback(
|
|
(e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => {
|
|
if (!e.features || e.features.length === 0) return
|
|
|
|
const feature = e.features[0]
|
|
const props = feature.properties
|
|
if (!props) return
|
|
|
|
// Get the corresponding district data for detailed info
|
|
const districtData = districtTimeData.find((d) => d.id === props.id)
|
|
if (!districtData) return
|
|
|
|
// Fly to the location
|
|
if (map) {
|
|
map.flyTo({
|
|
center: districtData.center,
|
|
zoom: 12,
|
|
duration: 1000,
|
|
pitch: 45,
|
|
bearing: 0,
|
|
})
|
|
}
|
|
|
|
// Set the selected district for popup
|
|
setSelectedDistrict(districtData)
|
|
},
|
|
[map, districtTimeData],
|
|
)
|
|
|
|
// Handle popup close
|
|
const handleClosePopup = useCallback(() => {
|
|
setSelectedDistrict(null)
|
|
}, [])
|
|
|
|
// Add an effect to hide other layers when timeline is active
|
|
useEffect(() => {
|
|
if (!map || !visible) return
|
|
|
|
// Hide incident markers when timeline mode is activated
|
|
if (map.getLayer("unclustered-point")) {
|
|
map.setLayoutProperty("unclustered-point", "visibility", "none")
|
|
}
|
|
|
|
// Hide clusters when timeline mode is activated
|
|
if (map.getLayer("clusters")) {
|
|
map.setLayoutProperty("clusters", "visibility", "none")
|
|
}
|
|
|
|
if (map.getLayer("cluster-count")) {
|
|
map.setLayoutProperty("cluster-count", "visibility", "none")
|
|
}
|
|
|
|
// Set up event handlers
|
|
const handleMouseEnter = () => {
|
|
if (map) map.getCanvas().style.cursor = "pointer"
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
if (map) map.getCanvas().style.cursor = ""
|
|
}
|
|
|
|
// Add event listeners
|
|
if (map.getLayer("timeline-markers")) {
|
|
map.on("click", "timeline-markers", handleMarkerClick)
|
|
map.on("mouseenter", "timeline-markers", handleMouseEnter)
|
|
map.on("mouseleave", "timeline-markers", handleMouseLeave)
|
|
}
|
|
|
|
return () => {
|
|
// Clean up event listeners
|
|
if (map) {
|
|
map.off("click", "timeline-markers", handleMarkerClick)
|
|
map.off("mouseenter", "timeline-markers", handleMouseEnter)
|
|
map.off("mouseleave", "timeline-markers", handleMouseLeave)
|
|
}
|
|
}
|
|
}, [map, visible, handleMarkerClick])
|
|
|
|
// Clean up on unmount or when visibility changes
|
|
useEffect(() => {
|
|
if (!visible) {
|
|
setSelectedDistrict(null)
|
|
}
|
|
}, [visible])
|
|
|
|
if (!visible) return null
|
|
|
|
return (
|
|
<>
|
|
<Source id="timeline-data" type="geojson" data={timelineGeoJSON}>
|
|
{/* Digital clock background */}
|
|
<Layer
|
|
id="timeline-markers-bg"
|
|
type="circle"
|
|
paint={{
|
|
"circle-color": [
|
|
"match",
|
|
["get", "timeOfDay"],
|
|
"morning",
|
|
"#FFEB3B",
|
|
"afternoon",
|
|
"#FF9800",
|
|
"evening",
|
|
"#3F51B5",
|
|
"night",
|
|
"#263238",
|
|
"#4CAF50", // Default color
|
|
],
|
|
"circle-radius": 18,
|
|
"circle-stroke-width": 2,
|
|
"circle-stroke-color": "#000000",
|
|
"circle-opacity": 0.9,
|
|
}}
|
|
/>
|
|
|
|
{/* Digital clock display */}
|
|
<Layer
|
|
id="timeline-markers"
|
|
type="symbol"
|
|
layout={{
|
|
"text-field": "{avgTime}",
|
|
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
|
|
"text-size": 12,
|
|
"text-anchor": "center",
|
|
"text-allow-overlap": true,
|
|
}}
|
|
paint={{
|
|
"text-color": [
|
|
"match",
|
|
["get", "timeOfDay"],
|
|
"night",
|
|
"#FFFFFF",
|
|
"evening",
|
|
"#FFFFFF",
|
|
"#000000", // Default text color
|
|
],
|
|
"text-halo-color": "#000000",
|
|
"text-halo-width": 0.5,
|
|
}}
|
|
/>
|
|
</Source>
|
|
|
|
{/* Custom Popup Component */}
|
|
{selectedDistrict && (
|
|
<TimelinePopup
|
|
longitude={selectedDistrict.center[0]}
|
|
latitude={selectedDistrict.center[1]}
|
|
onClose={handleClosePopup}
|
|
district={selectedDistrict}
|
|
/>
|
|
)}
|
|
|
|
|
|
</>
|
|
)
|
|
}
|