308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
import { Layer, Source } from "react-map-gl/mapbox"
|
|
import { ICrimes } from "@/app/_utils/types/crimes"
|
|
import mapboxgl from 'mapbox-gl'
|
|
import { format } from 'date-fns'
|
|
import { calculateAverageTimeOfDay } from '@/app/_utils/time'
|
|
|
|
|
|
interface TimelineLayerProps {
|
|
crimes: ICrimes[]
|
|
year: string
|
|
month: string
|
|
filterCategory: string | "all"
|
|
visible?: boolean
|
|
map?: mapboxgl.Map | null
|
|
}
|
|
|
|
export default function TimelineLayer({
|
|
crimes,
|
|
year,
|
|
month,
|
|
filterCategory,
|
|
visible = false,
|
|
map
|
|
}: TimelineLayerProps) {
|
|
// State to hold the currently selected district for popup display
|
|
const [selectedDistrict, setSelectedDistrict] = useState<string | null>(null)
|
|
const [popup, setPopup] = useState<mapboxgl.Popup | null>(null)
|
|
|
|
// 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
|
|
})
|
|
}
|
|
|
|
// Add valid incidents to the district group
|
|
crime.crime_incidents.forEach(incident => {
|
|
if (!incident.timestamp) return
|
|
if (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) return
|
|
|
|
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,
|
|
// Categorize by morning, afternoon, evening, night
|
|
timeOfDay: avgTimeInfo.timeOfDay,
|
|
// Additional statistics
|
|
earliestTime: format(avgTimeInfo.earliest, 'p'),
|
|
latestTime: format(avgTimeInfo.latest, 'p'),
|
|
mostFrequentHour: avgTimeInfo.mostFrequentHour,
|
|
// Group incidents by category for the popup
|
|
categoryCounts: group.incidents.reduce((acc, inc) => {
|
|
acc[inc.category] = (acc[inc.category] || 0) + 1
|
|
return acc
|
|
}, {} as Record<string, number>)
|
|
}
|
|
})
|
|
|
|
return result
|
|
}, [crimes, filterCategory])
|
|
|
|
// 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
|
|
},
|
|
geometry: {
|
|
type: "Point" as const,
|
|
coordinates: district.center
|
|
}
|
|
}))
|
|
}
|
|
}, [districtTimeData])
|
|
|
|
// Style time markers based on time of day
|
|
const getTimeMarkerColor = (timeOfDay: string) => {
|
|
switch (timeOfDay) {
|
|
case 'morning': return '#FFEB3B' // yellow
|
|
case 'afternoon': return '#FF9800' // orange
|
|
case 'evening': return '#3F51B5' // indigo
|
|
case 'night': return '#263238' // dark blue-grey
|
|
default: return '#4CAF50' // green fallback
|
|
}
|
|
}
|
|
|
|
// Event handlers
|
|
useEffect(() => {
|
|
if (!map || !visible) return
|
|
|
|
const handleTimeMarkerClick = (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
|
|
|
|
// Remove existing popup if any
|
|
if (popup) popup.remove()
|
|
|
|
// Create HTML content for popup
|
|
const categoriesHtml = Object.entries(districtData.categoryCounts)
|
|
.sort(([, countA], [, countB]) => countB - countA)
|
|
.slice(0, 5) // Top 5 categories
|
|
.map(([category, count]) =>
|
|
`<div class="flex justify-between mb-1">
|
|
<span class="text-xs">${category}</span>
|
|
<span class="text-xs font-semibold">${count}</span>
|
|
</div>`
|
|
).join('')
|
|
|
|
// Create popup
|
|
const newPopup = new mapboxgl.Popup({ closeButton: true, closeOnClick: false })
|
|
.setLngLat(feature.geometry.type === 'Point' ? (feature.geometry as GeoJSON.Point).coordinates as [number, number] : [0, 0])
|
|
.setHTML(`
|
|
<div class="p-3">
|
|
<div class="font-bold text-base mb-2">${districtData.name}</div>
|
|
<div class="mb-3">
|
|
<span class="text-xs text-gray-600">Average incident time</span>
|
|
<div class="flex items-center gap-2">
|
|
<div class="text-lg font-bold">${districtData.formattedTime}</div>
|
|
<div class="text-xs bg-gray-200 rounded px-2 py-0.5">${districtData.timeDescription}</div>
|
|
</div>
|
|
<div class="text-xs mt-1">Based on ${districtData.totalIncidents} incidents</div>
|
|
</div>
|
|
|
|
<div class="text-sm my-2">
|
|
<div class="flex justify-between mb-1">
|
|
<span>Earliest incident:</span>
|
|
<span class="font-medium">${districtData.earliestTime}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span>Latest incident:</span>
|
|
<span class="font-medium">${districtData.latestTime}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="border-t border-gray-200 my-2 pt-2">
|
|
<div class="text-xs font-medium mb-1">Top incident types:</div>
|
|
${categoriesHtml}
|
|
</div>
|
|
</div>
|
|
`)
|
|
.addTo(map)
|
|
|
|
// Store popup reference
|
|
setPopup(newPopup)
|
|
setSelectedDistrict(props.id)
|
|
|
|
// Remove popup when closed
|
|
newPopup.on('close', () => {
|
|
setPopup(null)
|
|
setSelectedDistrict(null)
|
|
})
|
|
}
|
|
|
|
// 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', handleTimeMarkerClick)
|
|
map.on('mouseenter', 'timeline-markers', handleMouseEnter)
|
|
map.on('mouseleave', 'timeline-markers', handleMouseLeave)
|
|
}
|
|
|
|
return () => {
|
|
// Clean up event listeners
|
|
if (map) {
|
|
map.off('click', 'timeline-markers', handleTimeMarkerClick)
|
|
map.off('mouseenter', 'timeline-markers', handleMouseEnter)
|
|
map.off('mouseleave', 'timeline-markers', handleMouseLeave)
|
|
|
|
// Remove popup if it exists
|
|
if (popup) {
|
|
popup.remove()
|
|
setPopup(null)
|
|
}
|
|
}
|
|
}
|
|
}, [map, visible, districtTimeData, popup])
|
|
|
|
// Clean up popup on unmount or when visibility changes
|
|
useEffect(() => {
|
|
if (!visible && popup) {
|
|
popup.remove()
|
|
setPopup(null)
|
|
setSelectedDistrict(null)
|
|
}
|
|
}, [visible, popup])
|
|
|
|
if (!visible) return null
|
|
|
|
return (
|
|
<Source id="timeline-data" type="geojson" data={timelineGeoJSON}>
|
|
{/* Time marker circles */}
|
|
<Layer
|
|
id="timeline-markers"
|
|
type="circle"
|
|
paint={{
|
|
'circle-color': [
|
|
'match',
|
|
['get', 'timeOfDay'],
|
|
'morning', '#FFEB3B',
|
|
'afternoon', '#FF9800',
|
|
'evening', '#3F51B5',
|
|
'night', '#263238',
|
|
'#4CAF50' // Default color
|
|
],
|
|
'circle-radius': 12,
|
|
'circle-stroke-width': 2,
|
|
'circle-stroke-color': '#ffffff',
|
|
'circle-opacity': 0.9
|
|
}}
|
|
/>
|
|
|
|
{/* Time labels */}
|
|
<Layer
|
|
id="timeline-labels"
|
|
type="symbol"
|
|
layout={{
|
|
'text-field': '{avgTime}',
|
|
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
|
|
'text-size': 10,
|
|
'text-anchor': 'center',
|
|
'text-allow-overlap': true
|
|
}}
|
|
paint={{
|
|
'text-color': [
|
|
'match',
|
|
['get', 'timeOfDay'],
|
|
'night', '#FFFFFF',
|
|
'evening', '#FFFFFF',
|
|
'#000000' // Default text color
|
|
]
|
|
}}
|
|
/>
|
|
</Source>
|
|
)
|
|
}
|