"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(null) const [popup, setPopup] = useState(null) // Process district data to extract average incident times const districtTimeData = useMemo(() => { // Group incidents by district const districtGroups = new Map, 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) } }) 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]) => `
${category} ${count}
` ).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(`
${districtData.name}
Average incident time
${districtData.formattedTime}
${districtData.timeDescription}
Based on ${districtData.totalIncidents} incidents
Earliest incident: ${districtData.earliestTime}
Latest incident: ${districtData.latestTime}
Top incident types:
${categoriesHtml}
`) .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 ( {/* Time marker circles */} {/* Time labels */} ) }