MIF_E31221222/sigap-website/app/_components/map/layers/timeline-layer.tsx

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>
)
}