feat: add digital clock and timezone markers to map

- Implemented DigitalClockMarker component to display a blinking digital clock at specified coordinates.
- Created TimeZonesDisplay component to show current times for different time zones on the map.
- Added styling for digital clock and time zone markers in globals.css.
- Introduced TimelinePopup component to display incident analysis for selected districts.
- Enhanced UnclusteredPointLayer and UnitsLayer components to manage visibility and interactions with markers.
- Updated map event handlers for improved user interaction and experience.
This commit is contained in:
vergiLgood1 2025-05-07 07:08:12 +07:00
parent 609a9c1327
commit da94277f4d
9 changed files with 968 additions and 554 deletions

View File

@ -215,6 +215,7 @@ export default function ClusterLayer({
if (map.getLayer("clusters")) { if (map.getLayer("clusters")) {
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none") map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
} }
if (map.getLayer("cluster-count")) { if (map.getLayer("cluster-count")) {
map.setLayoutProperty( map.setLayoutProperty(
"cluster-count", "cluster-count",

View File

@ -20,6 +20,7 @@ import type { IUnits } from "@/app/_utils/types/units"
import UnitsLayer from "./units-layer" import UnitsLayer from "./units-layer"
import DistrictFillLineLayer from "./district-layer" import DistrictFillLineLayer from "./district-layer"
import CrimePopup from "../pop-up/crime-popup" import CrimePopup from "../pop-up/crime-popup"
import TimeZonesDisplay from "../timezone"
// Interface for crime incident // Interface for crime incident
interface ICrimeIncident { interface ICrimeIncident {
@ -508,6 +509,9 @@ export default function Layers({
</> </>
)} )}
{/* Timeline Layer - only show if active control is timeline */}
<TimeZonesDisplay />
{/* Incident Popup */} {/* Incident Popup */}
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<CrimePopup <CrimePopup

View File

@ -1,11 +1,13 @@
"use client" "use client"
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState, useCallback } from "react"
import { Layer, Source } from "react-map-gl/mapbox" import { Layer, Source } from "react-map-gl/mapbox"
import { ICrimes } from "@/app/_utils/types/crimes" import type { ICrimes } from "@/app/_utils/types/crimes"
import mapboxgl from 'mapbox-gl' import type mapboxgl from "mapbox-gl"
import { format } from 'date-fns' import { format } from "date-fns"
import { calculateAverageTimeOfDay } from '@/app/_utils/time' import { calculateAverageTimeOfDay } from "@/app/_utils/time"
import TimelinePopup from "../pop-up/timeline-popup"
import TimeZonesDisplay from "../timezone"
interface TimelineLayerProps { interface TimelineLayerProps {
crimes: ICrimes[] crimes: ICrimes[]
@ -14,7 +16,7 @@ interface TimelineLayerProps {
filterCategory: string | "all" filterCategory: string | "all"
visible?: boolean visible?: boolean
map?: mapboxgl.Map | null map?: mapboxgl.Map | null
useAllData?: boolean // New prop to use all data useAllData?: boolean
} }
export default function TimelineLayer({ export default function TimelineLayer({
@ -24,31 +26,32 @@ export default function TimelineLayer({
filterCategory, filterCategory,
visible = false, visible = false,
map, map,
useAllData = false // Default to false useAllData = false,
}: TimelineLayerProps) { }: TimelineLayerProps) {
// State to hold the currently selected district for popup display // State for selected district and popup
const [selectedDistrict, setSelectedDistrict] = useState<string | null>(null) const [selectedDistrict, setSelectedDistrict] = useState<any | null>(null)
const [popup, setPopup] = useState<mapboxgl.Popup | null>(null) const [showTimeZones, setShowTimeZones] = useState<boolean>(true)
// Process district data to extract average incident times // Process district data to extract average incident times
const districtTimeData = useMemo(() => { const districtTimeData = useMemo(() => {
// Group incidents by district // Group incidents by district
const districtGroups = new Map<string, { const districtGroups = new Map<
districtId: string, string,
districtName: string, {
incidents: Array<{ timestamp: Date, category: string }>, districtId: string
districtName: string
incidents: Array<{ timestamp: Date; category: string }>
center: [number, number] center: [number, number]
}>() }
>()
crimes.forEach(crime => { crimes.forEach((crime) => {
if (!crime.districts || !crime.district_id) return if (!crime.districts || !crime.district_id) return
// Initialize district group if not exists // Initialize district group if not exists
if (!districtGroups.has(crime.district_id)) { if (!districtGroups.has(crime.district_id)) {
// Find a central location for the district from any incident // Find a central location for the district from any incident
const centerIncident = crime.crime_incidents.find(inc => const centerIncident = crime.crime_incidents.find((inc) => inc.locations?.latitude && inc.locations?.longitude)
inc.locations?.latitude && inc.locations?.longitude
)
const center: [number, number] = centerIncident const center: [number, number] = centerIncident
? [centerIncident.locations.longitude, centerIncident.locations.latitude] ? [centerIncident.locations.longitude, centerIncident.locations.latitude]
@ -58,12 +61,12 @@ export default function TimelineLayer({
districtId: crime.district_id, districtId: crime.district_id,
districtName: crime.districts.name, districtName: crime.districts.name,
incidents: [], incidents: [],
center center,
}) })
} }
// Filter incidents appropriately before adding // Filter incidents appropriately before adding
crime.crime_incidents.forEach(incident => { crime.crime_incidents.forEach((incident) => {
// Skip invalid incidents // Skip invalid incidents
if (!incident.timestamp) return if (!incident.timestamp) return
if (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) return if (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) return
@ -73,7 +76,7 @@ export default function TimelineLayer({
if (group) { if (group) {
group.incidents.push({ group.incidents.push({
timestamp: new Date(incident.timestamp), timestamp: new Date(incident.timestamp),
category: incident.crime_categories.name category: incident.crime_categories.name,
}) })
} }
}) })
@ -81,9 +84,9 @@ export default function TimelineLayer({
// Calculate average time for each district // Calculate average time for each district
const result = Array.from(districtGroups.values()) const result = Array.from(districtGroups.values())
.filter(group => group.incidents.length > 0 && group.center[0] !== 0) .filter((group) => group.incidents.length > 0 && group.center[0] !== 0)
.map(group => { .map((group) => {
const avgTimeInfo = calculateAverageTimeOfDay(group.incidents.map(inc => inc.timestamp)) const avgTimeInfo = calculateAverageTimeOfDay(group.incidents.map((inc) => inc.timestamp))
return { return {
id: group.districtId, id: group.districtId,
@ -94,31 +97,28 @@ export default function TimelineLayer({
formattedTime: avgTimeInfo.formattedTime, formattedTime: avgTimeInfo.formattedTime,
timeDescription: avgTimeInfo.description, timeDescription: avgTimeInfo.description,
totalIncidents: group.incidents.length, totalIncidents: group.incidents.length,
// Categorize by morning, afternoon, evening, night
timeOfDay: avgTimeInfo.timeOfDay, timeOfDay: avgTimeInfo.timeOfDay,
// Additional statistics earliestTime: format(avgTimeInfo.earliest, "p"),
earliestTime: format(avgTimeInfo.earliest, 'p'), latestTime: format(avgTimeInfo.latest, "p"),
latestTime: format(avgTimeInfo.latest, 'p'),
mostFrequentHour: avgTimeInfo.mostFrequentHour, mostFrequentHour: avgTimeInfo.mostFrequentHour,
// Group incidents by category for the popup categoryCounts: group.incidents.reduce(
categoryCounts: group.incidents.reduce((acc, inc) => { (acc, inc) => {
acc[inc.category] = (acc[inc.category] || 0) + 1 acc[inc.category] = (acc[inc.category] || 0) + 1
return acc return acc
}, {} as Record<string, number>) },
{} as Record<string, number>,
),
} }
}) })
// Add title to indicate all years data
const title = useAllData ? "All Years Data" : `Year: ${year}${month !== "all" ? `, Month: ${month}` : ""}`;
return result return result
}, [crimes, filterCategory, useAllData, year, month]) }, [crimes, filterCategory, year, month])
// Convert processed data to GeoJSON for display // Convert processed data to GeoJSON for display
const timelineGeoJSON = useMemo(() => { const timelineGeoJSON = useMemo(() => {
return { return {
type: "FeatureCollection" as const, type: "FeatureCollection" as const,
features: districtTimeData.map(district => ({ features: districtTimeData.map((district) => ({
type: "Feature" as const, type: "Feature" as const,
properties: { properties: {
id: district.id, id: district.id,
@ -126,56 +126,21 @@ export default function TimelineLayer({
avgTime: district.formattedTime, avgTime: district.formattedTime,
timeDescription: district.timeDescription, timeDescription: district.timeDescription,
totalIncidents: district.totalIncidents, totalIncidents: district.totalIncidents,
timeOfDay: district.timeOfDay timeOfDay: district.timeOfDay,
hour: district.avgHour,
minute: district.avgMinute,
}, },
geometry: { geometry: {
type: "Point" as const, type: "Point" as const,
coordinates: district.center coordinates: district.center,
} },
})) })),
} }
}, [districtTimeData]) }, [districtTimeData])
// Style time markers based on time of day // Handle marker click
const getTimeMarkerColor = (timeOfDay: string) => { const handleMarkerClick = useCallback(
switch (timeOfDay) { (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => {
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
}
}
// Add an effect to hide all other incident markers and clusters 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");
}
return () => {
// This cleanup won't restore visibility since that's handled by the parent component
// based on the activeControl value
};
}, [map, visible]);
// Event handlers
useEffect(() => {
if (!map || !visible) return
const handleTimeMarkerClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => {
if (!e.features || e.features.length === 0) return if (!e.features || e.features.length === 0) return
const feature = e.features[0] const feature = e.features[0]
@ -183,155 +148,150 @@ export default function TimelineLayer({
if (!props) return if (!props) return
// Get the corresponding district data for detailed info // Get the corresponding district data for detailed info
const districtData = districtTimeData.find(d => d.id === props.id) const districtData = districtTimeData.find((d) => d.id === props.id)
if (!districtData) return if (!districtData) return
// Remove existing popup if any // Fly to the location
if (popup) popup.remove() if (map) {
map.flyTo({
// Create HTML content for popup center: districtData.center,
const categoriesHtml = Object.entries(districtData.categoryCounts) zoom: 12,
.sort(([, countA], [, countB]) => countB - countA) duration: 1000,
.slice(0, 5) // Top 5 categories pitch: 45,
.map(([category, count]) => bearing: 0,
`<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 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 // Set up event handlers
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (map) map.getCanvas().style.cursor = 'pointer' if (map) map.getCanvas().style.cursor = "pointer"
} }
const handleMouseLeave = () => { const handleMouseLeave = () => {
if (map) map.getCanvas().style.cursor = '' if (map) map.getCanvas().style.cursor = ""
} }
// Add event listeners // Add event listeners
if (map.getLayer('timeline-markers')) { if (map.getLayer("timeline-markers")) {
map.on('click', 'timeline-markers', handleTimeMarkerClick) map.on("click", "timeline-markers", handleMarkerClick)
map.on('mouseenter', 'timeline-markers', handleMouseEnter) map.on("mouseenter", "timeline-markers", handleMouseEnter)
map.on('mouseleave', 'timeline-markers', handleMouseLeave) map.on("mouseleave", "timeline-markers", handleMouseLeave)
} }
return () => { return () => {
// Clean up event listeners // Clean up event listeners
if (map) { if (map) {
map.off('click', 'timeline-markers', handleTimeMarkerClick) map.off("click", "timeline-markers", handleMarkerClick)
map.off('mouseenter', 'timeline-markers', handleMouseEnter) map.off("mouseenter", "timeline-markers", handleMouseEnter)
map.off('mouseleave', 'timeline-markers', handleMouseLeave) map.off("mouseleave", "timeline-markers", handleMouseLeave)
}
}
}, [map, visible, handleMarkerClick])
// Remove popup if it exists // Clean up on unmount or when visibility changes
if (popup) {
popup.remove()
setPopup(null)
}
}
}
}, [map, visible, districtTimeData, popup])
// Clean up popup on unmount or when visibility changes
useEffect(() => { useEffect(() => {
if (!visible && popup) { if (!visible) {
popup.remove()
setPopup(null)
setSelectedDistrict(null) setSelectedDistrict(null)
} }
}, [visible, popup]) }, [visible])
if (!visible) return null if (!visible) return null
return ( return (
<>
<Source id="timeline-data" type="geojson" data={timelineGeoJSON}> <Source id="timeline-data" type="geojson" data={timelineGeoJSON}>
{/* Time marker circles */} {/* Digital clock background */}
<Layer <Layer
id="timeline-markers" id="timeline-markers-bg"
type="circle" type="circle"
paint={{ paint={{
'circle-color': [ "circle-color": [
'match', "match",
['get', 'timeOfDay'], ["get", "timeOfDay"],
'morning', '#FFEB3B', "morning",
'afternoon', '#FF9800', "#FFEB3B",
'evening', '#3F51B5', "afternoon",
'night', '#263238', "#FF9800",
'#4CAF50' // Default color "evening",
"#3F51B5",
"night",
"#263238",
"#4CAF50", // Default color
], ],
'circle-radius': 12, "circle-radius": 18,
'circle-stroke-width': 2, "circle-stroke-width": 2,
'circle-stroke-color': '#ffffff', "circle-stroke-color": "#000000",
'circle-opacity': 0.9 "circle-opacity": 0.9,
}} }}
/> />
{/* Time labels */} {/* Digital clock display */}
<Layer <Layer
id="timeline-labels" id="timeline-markers"
type="symbol" type="symbol"
layout={{ layout={{
'text-field': '{avgTime}', "text-field": "{avgTime}",
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
'text-size': 10, "text-size": 12,
'text-anchor': 'center', "text-anchor": "center",
'text-allow-overlap': true "text-allow-overlap": true,
}} }}
paint={{ paint={{
'text-color': [ "text-color": [
'match', "match",
['get', 'timeOfDay'], ["get", "timeOfDay"],
'night', '#FFFFFF', "night",
'evening', '#FFFFFF', "#FFFFFF",
'#000000' // Default text color "evening",
] "#FFFFFF",
"#000000", // Default text color
],
"text-halo-color": "#000000",
"text-halo-width": 0.5,
}} }}
/> />
</Source> </Source>
{/* Custom Popup Component */}
{selectedDistrict && (
<TimelinePopup
longitude={selectedDistrict.center[0]}
latitude={selectedDistrict.center[1]}
onClose={handleClosePopup}
district={selectedDistrict}
/>
)}
</>
) )
} }

View File

@ -1,7 +1,7 @@
"use client" "use client"
import type { IUnclusteredPointLayerProps } from "@/app/_utils/types/map" import type { IUnclusteredPointLayerProps } from "@/app/_utils/types/map"
import { useEffect, useCallback } from "react" import { useEffect, useCallback, useRef } from "react"
export default function UnclusteredPointLayer({ export default function UnclusteredPointLayer({
visible = true, visible = true,
@ -10,6 +10,9 @@ export default function UnclusteredPointLayer({
filterCategory = "all", filterCategory = "all",
focusedDistrictId, focusedDistrictId,
}: IUnclusteredPointLayerProps) { }: IUnclusteredPointLayerProps) {
// Add a ref to track if we're currently interacting with a marker
const isInteractingWithMarker = useRef(false);
const handleIncidentClick = useCallback( const handleIncidentClick = useCallback(
(e: any) => { (e: any) => {
if (!map) return if (!map) return
@ -17,6 +20,9 @@ export default function UnclusteredPointLayer({
const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] }) const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] })
if (!features || features.length === 0) return if (!features || features.length === 0) return
// Set flag to indicate we're interacting with a marker
isInteractingWithMarker.current = true;
const incident = features[0] const incident = features[0]
if (!incident.properties) return if (!incident.properties) return
@ -37,13 +43,18 @@ export default function UnclusteredPointLayer({
console.log("Incident clicked:", incidentDetails) console.log("Incident clicked:", incidentDetails)
// Ensure markers stay visible when clicking on them
if (map.getLayer("unclustered-point")) {
map.setLayoutProperty("unclustered-point", "visibility", "visible");
}
// First fly to the incident location // First fly to the incident location
map.flyTo({ map.flyTo({
center: [incidentDetails.longitude, incidentDetails.latitude], center: [incidentDetails.longitude, incidentDetails.latitude],
zoom: 15, zoom: 15,
bearing: 0, bearing: 0,
pitch: 45, pitch: 45,
duration: 1000, duration: 2000,
}) })
// Then dispatch the incident_click event to show the popup // Then dispatch the incident_click event to show the popup
@ -55,6 +66,11 @@ export default function UnclusteredPointLayer({
// Dispatch on both the map canvas and document to ensure it's caught // Dispatch on both the map canvas and document to ensure it's caught
map.getCanvas().dispatchEvent(customEvent) map.getCanvas().dispatchEvent(customEvent)
document.dispatchEvent(customEvent) document.dispatchEvent(customEvent)
// Reset the flag after a delay to allow the event to process
setTimeout(() => {
isInteractingWithMarker.current = false;
}, 500);
}, },
[map], [map],
) )
@ -130,9 +146,10 @@ export default function UnclusteredPointLayer({
"circle-stroke-width": 1, "circle-stroke-width": 1,
"circle-stroke-color": "#fff", "circle-stroke-color": "#fff",
}, },
// layout: { layout: {
// visibility: focusedDistrictId ? "visible" : "visible", // Only hide markers if a district is focused AND we're not interacting with a marker
// }, visibility: focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible",
},
}, },
firstSymbolId, firstSymbolId,
) )
@ -145,8 +162,9 @@ export default function UnclusteredPointLayer({
map.getCanvas().style.cursor = "" map.getCanvas().style.cursor = ""
}) })
} else { } else {
// Update visibility based on focused district // Update visibility based on focused district, but keep visible when interacting with markers
map.setLayoutProperty("unclustered-point", "visibility", focusedDistrictId ? "none" : "visible") const newVisibility = focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible";
map.setLayoutProperty("unclustered-point", "visibility", newVisibility);
} }
// Always ensure click handler is properly registered // Always ensure click handler is properly registered
@ -157,6 +175,16 @@ export default function UnclusteredPointLayer({
} }
} }
if (map.getLayer("crime-incidents")) {
const newVisibility = focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible";
map.setLayoutProperty("crime-incidents", "visibility", newVisibility);
}
if (map.getLayer("unclustered-point")) {
const newVisibility = focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible";
map.setLayoutProperty("unclustered-point", "visibility", newVisibility);
}
// Check if style is loaded and set up layer accordingly // Check if style is loaded and set up layer accordingly
if (map.isStyleLoaded()) { if (map.isStyleLoaded()) {
setupLayerAndSource() setupLayerAndSource()

View File

@ -1,16 +1,16 @@
"use client" "use client"
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState, useCallback } from "react"
import { Layer, Source } from "react-map-gl/mapbox" import { Layer, Source } from "react-map-gl/mapbox"
import { ICrimes, IDistanceResult } from "@/app/_utils/types/crimes" import type { ICrimes } from "@/app/_utils/types/crimes"
import { IUnits } from "@/app/_utils/types/units" import type { IUnits } from "@/app/_utils/types/units"
import mapboxgl from 'mapbox-gl' import type mapboxgl from "mapbox-gl"
import { useQuery } from '@tanstack/react-query' import { useQuery } from "@tanstack/react-query"
import { generateCategoryColorMap, getCategoryColor } from '@/app/_utils/colors' import { generateCategoryColorMap } from "@/app/_utils/colors"
import UnitPopup from '../pop-up/unit-popup' import UnitPopup from "../pop-up/unit-popup"
import IncidentPopup from '../pop-up/incident-popup' import IncidentPopup from "../pop-up/incident-popup"
import { calculateDistances } from '@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action' import { calculateDistances } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/action"
interface UnitsLayerProps { interface UnitsLayerProps {
crimes: ICrimes[] crimes: ICrimes[]
@ -21,29 +21,23 @@ interface UnitsLayerProps {
} }
// Custom hook for fetching distance data // Custom hook for fetching distance data
const useDistanceData = (entityId?: string, isUnit: boolean = false, districtId?: string) => { const useDistanceData = (entityId?: string, isUnit = false, districtId?: string) => {
// Skip the query when no entity is selected // Skip the query when no entity is selected
return useQuery({ return useQuery({
queryKey: ['distance-incidents', entityId, isUnit, districtId], queryKey: ["distance-incidents", entityId, isUnit, districtId],
queryFn: async () => { queryFn: async () => {
if (!entityId) return []; if (!entityId) return []
const unitId = isUnit ? entityId : undefined; const unitId = isUnit ? entityId : undefined
const result = await calculateDistances(unitId, districtId); const result = await calculateDistances(unitId, districtId)
return result; return result
}, },
enabled: !!entityId, // Only run query when there's an entityId enabled: !!entityId, // Only run query when there's an entityId
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
gcTime: 10 * 60 * 1000, // Keep cache for 10 minutes gcTime: 10 * 60 * 1000, // Keep cache for 10 minutes
}); })
}; }
export default function UnitsLayer({ export default function UnitsLayer({ crimes, units = [], filterCategory, visible = false, map }: UnitsLayerProps) {
crimes,
units = [],
filterCategory,
visible = false,
map
}: UnitsLayerProps) {
const [loadedUnits, setLoadedUnits] = useState<IUnits[]>([]) const [loadedUnits, setLoadedUnits] = useState<IUnits[]>([])
const loadedUnitsRef = useRef<IUnits[]>([]) const loadedUnitsRef = useRef<IUnits[]>([])
@ -58,37 +52,38 @@ export default function UnitsLayer({
const { data: distances = [], isLoading: isLoadingDistances } = useDistanceData( const { data: distances = [], isLoading: isLoadingDistances } = useDistanceData(
selectedEntityId, selectedEntityId,
isUnitSelected, isUnitSelected,
selectedDistrictId selectedDistrictId,
); )
// Use either provided units or loaded units // Use either provided units or loaded units
const unitsData = useMemo(() => { const unitsData = useMemo(() => {
return units.length > 0 ? units : (loadedUnits || []) return units.length > 0 ? units : loadedUnits || []
}, [units, loadedUnits]) }, [units, loadedUnits])
// Extract all unique crime categories for color generation // Extract all unique crime categories for color generation
const uniqueCategories = useMemo(() => { const uniqueCategories = useMemo(() => {
const categories = new Set<string>(); const categories = new Set<string>()
crimes.forEach(crime => { crimes.forEach((crime) => {
crime.crime_incidents.forEach(incident => { crime.crime_incidents.forEach((incident) => {
if (incident.crime_categories?.name) { if (incident.crime_categories?.name) {
categories.add(incident.crime_categories.name); categories.add(incident.crime_categories.name)
} }
}); })
}); })
return Array.from(categories); return Array.from(categories)
}, [crimes]); }, [crimes])
// Generate color map for all categories // Generate color map for all categories
const categoryColorMap = useMemo(() => { const categoryColorMap = useMemo(() => {
return generateCategoryColorMap(uniqueCategories); return generateCategoryColorMap(uniqueCategories)
}, [uniqueCategories]); }, [uniqueCategories])
// Process units data to GeoJSON format // Process units data to GeoJSON format
const unitsGeoJSON = useMemo(() => { const unitsGeoJSON = useMemo(() => {
return { return {
type: "FeatureCollection" as const, type: "FeatureCollection" as const,
features: unitsData.map(unit => ({ features: unitsData
.map((unit) => ({
type: "Feature" as const, type: "Feature" as const,
properties: { properties: {
id: unit.code_unit, id: unit.code_unit,
@ -101,27 +96,26 @@ export default function UnitsLayer({
}, },
geometry: { geometry: {
type: "Point" as const, type: "Point" as const,
coordinates: [unit.longitude || 0, unit.latitude || 0] coordinates: [unit.longitude || 0, unit.latitude || 0],
} },
})).filter(feature => }))
feature.geometry.coordinates[0] !== 0 && .filter((feature) => feature.geometry.coordinates[0] !== 0 && feature.geometry.coordinates[1] !== 0),
feature.geometry.coordinates[1] !== 0
)
} }
}, [unitsData]) }, [unitsData])
// Process incident data to GeoJSON format // Process incident data to GeoJSON format
const incidentsGeoJSON = useMemo(() => { const incidentsGeoJSON = useMemo(() => {
const features: any[] = []; const features: any[] = []
crimes.forEach(crime => { crimes.forEach((crime) => {
crime.crime_incidents.forEach(incident => { crime.crime_incidents.forEach((incident) => {
// Skip incidents without location data or filtered by category // Skip incidents without location data or filtered by category
if ( if (
!incident.locations?.latitude || !incident.locations?.latitude ||
!incident.locations?.longitude || !incident.locations?.longitude ||
(filterCategory !== "all" && incident.crime_categories.name !== filterCategory) (filterCategory !== "all" && incident.crime_categories.name !== filterCategory)
) return; )
return
features.push({ features.push({
type: "Feature" as const, type: "Feature" as const,
@ -132,33 +126,34 @@ export default function UnitsLayer({
date: incident.timestamp, date: incident.timestamp,
district: crime.districts?.name || "", district: crime.districts?.name || "",
district_id: crime.district_id, district_id: crime.district_id,
categoryColor: categoryColorMap[incident.crime_categories.name] || '#22c55e', categoryColor: categoryColorMap[incident.crime_categories.name] || "#22c55e",
}, },
geometry: { geometry: {
type: "Point" as const, type: "Point" as const,
coordinates: [incident.locations.longitude, incident.locations.latitude] coordinates: [incident.locations.longitude, incident.locations.latitude],
} },
}); })
}); })
}); })
return { return {
type: "FeatureCollection" as const, type: "FeatureCollection" as const,
features features,
}; }
}, [crimes, filterCategory, categoryColorMap]); }, [crimes, filterCategory, categoryColorMap])
// Create lines between units and incidents within their districts // Create lines between units and incidents within their districts
const connectionLinesGeoJSON = useMemo(() => { const connectionLinesGeoJSON = useMemo(() => {
if (!unitsData.length || !crimes.length) return { if (!unitsData.length || !crimes.length)
return {
type: "FeatureCollection" as const, type: "FeatureCollection" as const,
features: [] features: [],
} }
// Map district IDs to their units // Map district IDs to their units
const districtUnitsMap = new Map<string, IUnits[]>() const districtUnitsMap = new Map<string, IUnits[]>()
unitsData.forEach(unit => { unitsData.forEach((unit) => {
if (!unit.district_id || !unit.longitude || !unit.latitude) return if (!unit.district_id || !unit.longitude || !unit.latitude) return
if (!districtUnitsMap.has(unit.district_id)) { if (!districtUnitsMap.has(unit.district_id)) {
@ -170,22 +165,23 @@ export default function UnitsLayer({
// Create lines from units to incidents in their district // Create lines from units to incidents in their district
const lineFeatures: any[] = [] const lineFeatures: any[] = []
crimes.forEach(crime => { crimes.forEach((crime) => {
// Get all units in this district // Get all units in this district
const districtUnits = districtUnitsMap.get(crime.district_id) || [] const districtUnits = districtUnitsMap.get(crime.district_id) || []
if (!districtUnits.length) return if (!districtUnits.length) return
// For each incident in this district // For each incident in this district
crime.crime_incidents.forEach(incident => { crime.crime_incidents.forEach((incident) => {
// Skip incidents without location data or filtered by category // Skip incidents without location data or filtered by category
if ( if (
!incident.locations?.latitude || !incident.locations?.latitude ||
!incident.locations?.longitude || !incident.locations?.longitude ||
(filterCategory !== "all" && incident.crime_categories.name !== filterCategory) (filterCategory !== "all" && incident.crime_categories.name !== filterCategory)
) return )
return
// Create a line from each unit in this district to this incident // Create a line from each unit in this district to this incident
districtUnits.forEach(unit => { districtUnits.forEach((unit) => {
if (!unit.longitude || !unit.latitude) return if (!unit.longitude || !unit.latitude) return
lineFeatures.push({ lineFeatures.push({
@ -197,15 +193,15 @@ export default function UnitsLayer({
district_id: crime.district_id, district_id: crime.district_id,
district_name: crime.districts.name, district_name: crime.districts.name,
category: incident.crime_categories.name, category: incident.crime_categories.name,
lineColor: categoryColorMap[incident.crime_categories.name] || '#22c55e', lineColor: categoryColorMap[incident.crime_categories.name] || "#22c55e",
}, },
geometry: { geometry: {
type: "LineString" as const, type: "LineString" as const,
coordinates: [ coordinates: [
[unit.longitude, unit.latitude], [unit.longitude, unit.latitude],
[incident.locations.longitude, incident.locations.latitude] [incident.locations.longitude, incident.locations.latitude],
] ],
} },
}) })
}) })
}) })
@ -213,49 +209,108 @@ export default function UnitsLayer({
return { return {
type: "FeatureCollection" as const, type: "FeatureCollection" as const,
features: lineFeatures features: lineFeatures,
} }
}, [unitsData, crimes, filterCategory, categoryColorMap]) }, [unitsData, crimes, filterCategory, categoryColorMap])
useEffect(() => { // Handle unit click
if (!map || !visible) return const handleUnitClick = useCallback(
(
const handleUnitClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => { 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 if (!e.features || e.features.length === 0) return
e.originalEvent.stopPropagation()
e.preventDefault()
const feature = e.features[0] const feature = e.features[0]
const properties = feature.properties const properties = feature.properties
if (!properties) return if (!properties) return
// Find the unit in our data // Find the unit in our data
const unit = unitsData.find(u => u.code_unit === properties.id); const unit = unitsData.find((u) => u.code_unit === properties.id)
if (!unit) return; if (!unit) return
// Fly to the unit location
map.flyTo({
center: [unit.longitude || 0, unit.latitude || 0],
zoom: 14,
pitch: 45,
bearing: 0,
duration: 1000,
})
// Set the selected unit and query parameters // Set the selected unit and query parameters
setSelectedUnit(unit); setSelectedUnit(unit)
setSelectedIncident(null); // Clear any selected incident setSelectedIncident(null) // Clear any selected incident
setSelectedEntityId(properties.id); setSelectedEntityId(properties.id)
setIsUnitSelected(true); setIsUnitSelected(true)
setSelectedDistrictId(properties.district_id); setSelectedDistrictId(properties.district_id)
// Highlight the connected lines for this unit // Highlight the connected lines for this unit
if (map.getLayer('units-connection-lines')) { if (map.getLayer("units-connection-lines")) {
map.setFilter('units-connection-lines', [ map.setFilter("units-connection-lines", ["==", ["get", "unit_id"], properties.id])
'==',
['get', 'unit_id'],
properties.id
])
}
} }
const handleIncidentClick = (e: mapboxgl.MapMouseEvent & { features?: mapboxgl.MapboxGeoJSONFeature[] }) => { // Dispatch a custom event for other components to react to
if (!e.features || e.features.length === 0) return; 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,
})
const feature = e.features[0]; map.getCanvas().dispatchEvent(customEvent)
const properties = feature.properties; document.dispatchEvent(customEvent)
},
[],
)
if (!properties) return; // 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: 1000,
})
// Create incident object from properties // Create incident object from properties
const incident = { const incident = {
@ -265,84 +320,144 @@ export default function UnitsLayer({
date: properties.date, date: properties.date,
district: properties.district, district: properties.district,
district_id: properties.district_id, district_id: properties.district_id,
}; longitude,
latitude,
}
// Set the selected incident and query parameters // Set the selected incident and query parameters
setSelectedIncident({ setSelectedIncident(incident)
...incident, setSelectedUnit(null) // Clear any selected unit
latitude: feature.geometry.type === 'Point' ? setSelectedEntityId(properties.id)
(feature.geometry as any).coordinates[1] : 0, setIsUnitSelected(false)
longitude: feature.geometry.type === 'Point' ? setSelectedDistrictId(properties.district_id)
(feature.geometry as any).coordinates[0] : 0
});
setSelectedUnit(null);
setSelectedEntityId(properties.id);
setIsUnitSelected(false);
setSelectedDistrictId(properties.district_id);
// Highlight the connected lines for this incident // Highlight the connected lines for this incident
if (map.getLayer('units-connection-lines')) { if (map.getLayer("units-connection-lines")) {
map.setFilter('units-connection-lines', [ map.setFilter("units-connection-lines", ["==", ["get", "incident_id"], properties.id])
'==',
['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
// Define event handlers that can be referenced for both adding and removing // Define event handlers that can be referenced for both adding and removing
const handleMouseEnter = () => { const handleMouseEnter = () => {
map.getCanvas().style.cursor = 'pointer' map.getCanvas().style.cursor = "pointer"
} }
const handleMouseLeave = () => { const handleMouseLeave = () => {
map.getCanvas().style.cursor = '' map.getCanvas().style.cursor = ""
} }
// Add click event for units-points layer // Add click event for units-points layer
if (map.getLayer('units-points')) { if (map.getLayer("units-points")) {
map.on('click', 'units-points', handleUnitClick) map.off("click", "units-points", unitClickHandler)
map.on("click", "units-points", unitClickHandler)
// Change cursor on hover // Change cursor on hover
map.on('mouseenter', 'units-points', handleMouseEnter) map.on("mouseenter", "units-points", handleMouseEnter)
map.on('mouseleave', 'units-points', handleMouseLeave) map.on("mouseleave", "units-points", handleMouseLeave)
} }
// Add click event for incidents-points layer // Add click event for incidents-points layer
if (map.getLayer('incidents-points')) { if (map.getLayer("incidents-points")) {
map.on('click', 'incidents-points', handleIncidentClick) map.off("click", "incidents-points", incidentClickHandler)
map.on("click", "incidents-points", incidentClickHandler)
// Change cursor on hover // Change cursor on hover
map.on('mouseenter', 'incidents-points', handleMouseEnter) map.on("mouseenter", "incidents-points", handleMouseEnter)
map.on('mouseleave', 'incidents-points', handleMouseLeave) map.on("mouseleave", "incidents-points", handleMouseLeave)
} }
return () => { return () => {
if (map.getLayer('units-points')) { if (map) {
map.off('click', 'units-points', handleUnitClick) if (map.getLayer("units-points")) {
map.off('mouseenter', 'units-points', handleMouseEnter) map.off("click", "units-points", unitClickHandler)
map.off('mouseleave', 'units-points', handleMouseLeave) map.off("mouseenter", "units-points", handleMouseEnter)
map.off("mouseleave", "units-points", handleMouseLeave)
} }
if (map.getLayer('incidents-points')) { if (map.getLayer("incidents-points")) {
map.off('click', 'incidents-points', handleIncidentClick) map.off("click", "incidents-points", incidentClickHandler)
map.off('mouseenter', 'incidents-points', handleMouseEnter) map.off("mouseenter", "incidents-points", handleMouseEnter)
map.off('mouseleave', 'incidents-points', handleMouseLeave) map.off("mouseleave", "incidents-points", handleMouseLeave)
} }
} }
}, [map, visible, unitsData]) }
}, [map, visible, unitClickHandler, incidentClickHandler])
// Reset map filters when popup is closed // Reset map filters when popup is closed
const handleClosePopup = () => { const handleClosePopup = useCallback(() => {
setSelectedUnit(null); setSelectedUnit(null)
setSelectedIncident(null); setSelectedIncident(null)
setSelectedEntityId(undefined); setSelectedEntityId(undefined)
setSelectedDistrictId(undefined); setSelectedDistrictId(undefined)
if (map && map.getLayer('units-connection-lines')) { if (map && map.getLayer("units-connection-lines")) {
map.setFilter('units-connection-lines', ['has', 'unit_id']); 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 if (!visible) return null
@ -354,11 +469,11 @@ export default function UnitsLayer({
id="units-points" id="units-points"
type="circle" type="circle"
paint={{ paint={{
'circle-radius': 8, "circle-radius": 8,
'circle-color': '#1e40af', // Deep blue for police units "circle-color": "#1e40af", // Deep blue for police units
'circle-stroke-width': 2, "circle-stroke-width": 2,
'circle-stroke-color': '#ffffff', "circle-stroke-color": "#ffffff",
'circle-opacity': 0.8 "circle-opacity": 0.8,
}} }}
/> />
@ -367,18 +482,18 @@ export default function UnitsLayer({
id="units-symbols" id="units-symbols"
type="symbol" type="symbol"
layout={{ layout={{
'text-field': ['get', 'name'], "text-field": ["get", "name"],
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
'text-size': 12, "text-size": 12,
'text-offset': [0, -2], "text-offset": [0, -2],
'text-anchor': 'bottom', "text-anchor": "bottom",
'text-allow-overlap': false, "text-allow-overlap": false,
'text-ignore-placement': false "text-ignore-placement": false,
}} }}
paint={{ paint={{
'text-color': '#ffffff', "text-color": "#ffffff",
'text-halo-color': '#000000', "text-halo-color": "#000000",
'text-halo-width': 1 "text-halo-width": 1,
}} }}
/> />
</Source> </Source>
@ -389,12 +504,12 @@ export default function UnitsLayer({
id="incidents-points" id="incidents-points"
type="circle" type="circle"
paint={{ paint={{
'circle-radius': 6, "circle-radius": 6,
// Use the pre-computed color stored in the properties // Use the pre-computed color stored in the properties
'circle-color': ['get', 'categoryColor'], "circle-color": ["get", "categoryColor"],
'circle-stroke-width': 1, "circle-stroke-width": 1,
'circle-stroke-color': '#ffffff', "circle-stroke-color": "#ffffff",
'circle-opacity': 0.8 "circle-opacity": 0.8,
}} }}
/> />
</Source> </Source>
@ -406,11 +521,11 @@ export default function UnitsLayer({
type="line" type="line"
paint={{ paint={{
// Use the pre-computed color stored in the properties // Use the pre-computed color stored in the properties
'line-color': ['get', 'lineColor'], "line-color": ["get", "lineColor"],
'line-width': 3, "line-width": 3,
'line-opacity': 0.9, "line-opacity": 0.9,
'line-blur': 0.5, "line-blur": 0.5,
'line-dasharray': [3, 1], "line-dasharray": [3, 1],
}} }}
/> />
</Source> </Source>
@ -428,7 +543,7 @@ export default function UnitsLayer({
address: selectedUnit.address || "No address", address: selectedUnit.address || "No address",
phone: selectedUnit.phone || "No phone", phone: selectedUnit.phone || "No phone",
district: selectedUnit.districts?.name, district: selectedUnit.districts?.name,
district_id: selectedUnit.district_id district_id: selectedUnit.district_id,
}} }}
distances={distances} distances={distances}
isLoadingDistances={isLoadingDistances} isLoadingDistances={isLoadingDistances}

View File

@ -0,0 +1,83 @@
"use client"
import { useState, useEffect } from "react"
import { Marker } from "react-map-gl/mapbox"
interface DigitalClockMarkerProps {
longitude: number
latitude: number
time: string
timeOfDay: string
onClick?: () => void
}
export default function DigitalClockMarker({ longitude, latitude, time, timeOfDay, onClick }: DigitalClockMarkerProps) {
const [isBlinking, setIsBlinking] = useState(false)
// Blink effect for the colon in the time display
useEffect(() => {
const interval = setInterval(() => {
setIsBlinking((prev) => !prev)
}, 1000)
return () => clearInterval(interval)
}, [])
// Get background color based on time of day
const getBackgroundColor = () => {
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
}
}
// Get text color based on time of day
const getTextColor = () => {
switch (timeOfDay) {
case "morning":
case "afternoon":
return "#000000"
case "evening":
case "night":
return "#FFFFFF"
default:
return "#000000"
}
}
// Format time with blinking colon
const formatTime = (timeString: string) => {
if (!isBlinking) {
return timeString
}
return timeString.replace(":", " ")
}
return (
<Marker longitude={longitude} latitude={latitude}>
<div className="cursor-pointer transform -translate-x-1/2 -translate-y-1/2" onClick={onClick}>
<div
className="px-2 py-1 rounded-md border-2 border-black"
style={{
backgroundColor: getBackgroundColor(),
color: getTextColor(),
fontFamily: "monospace",
fontWeight: "bold",
fontSize: "0.875rem",
boxShadow: "0 0 5px rgba(0, 0, 0, 0.5)",
}}
>
{formatTime(time)}
</div>
</div>
</Marker>
)
}

View File

@ -0,0 +1,122 @@
"use client"
import { Popup } from "react-map-gl/mapbox"
import { X } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../ui/card"
import { Button } from "../../ui/button"
import { Badge } from "../../ui/badge"
interface TimelinePopupProps {
longitude: number
latitude: number
onClose: () => void
district: {
id: string
name: string
formattedTime: string
timeDescription: string
totalIncidents: number
earliestTime: string
latestTime: string
mostFrequentHour: number
categoryCounts: Record<string, number>
timeOfDay: string
}
}
export default function TimelinePopup({
longitude,
latitude,
onClose,
district,
}: TimelinePopupProps) {
// Get top 5 categories
const topCategories = Object.entries(district.categoryCounts)
.sort(([, countA], [, countB]) => countB - countA)
.slice(0, 5)
// Get time of day color
const getTimeOfDayColor = (timeOfDay: string) => {
switch (timeOfDay) {
case "morning":
return "bg-yellow-400 text-black"
case "afternoon":
return "bg-orange-500 text-white"
case "evening":
return "bg-indigo-600 text-white"
case "night":
return "bg-slate-800 text-white"
default:
return "bg-green-500 text-white"
}
}
return (
<Popup
longitude={longitude}
latitude={latitude}
anchor="bottom"
closeOnClick={false}
onClose={onClose}
className="z-10"
maxWidth="300px"
>
<Card className="border-0 shadow-none">
<CardHeader className="p-3 pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{district.name}</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<CardDescription className="text-xs">
Average incident time analysis
</CardDescription>
</CardHeader>
<CardContent className="p-3 pt-0">
<div className="mb-3">
<div className="flex items-center gap-2 mb-1">
<div className="text-xl font-bold font-mono">{district.formattedTime}</div>
<Badge variant="outline" className={`${getTimeOfDayColor(district.timeOfDay)}`}>
{district.timeDescription}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
Based on {district.totalIncidents} incidents
</div>
</div>
<div className="text-sm space-y-1 mb-3">
<div className="flex justify-between">
<span>Earliest incident:</span>
<span className="font-medium">{district.earliestTime}</span>
</div>
<div className="flex justify-between">
<span>Latest incident:</span>
<span className="font-medium">{district.latestTime}</span>
</div>
</div>
<div className="border-t border-border pt-2">
<div className="text-xs font-medium mb-1">Top incident types:</div>
<div className="space-y-1">
{topCategories.map(([category, count]) => (
<div key={category} className="flex justify-between">
<span className="text-xs truncate mr-2">{category}</span>
<span className="text-xs font-semibold">{count}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</Popup>
)
}

View File

@ -0,0 +1,65 @@
"use client"
import { useEffect, useState } from "react"
import { Marker } from "react-map-gl/mapbox"
interface TimeZoneMarker {
name: string
offset: number
longitude: number
latitude: number
}
const TIME_ZONES: TimeZoneMarker[] = [
{ name: "WIB", offset: 7, longitude: 106.8456, latitude: -6.2088 }, // Jakarta
{ name: "WITA", offset: 8, longitude: 115.1889, latitude: -8.4095 }, // Denpasar
{ name: "WIT", offset: 9, longitude: 140.7887, latitude: -2.5916 }, // Jayapura
]
export default function TimeZonesDisplay() {
const [currentTimes, setCurrentTimes] = useState<Record<string, string>>({})
useEffect(() => {
const updateTimes = () => {
const now = new Date()
const times: Record<string, string> = {}
TIME_ZONES.forEach((zone) => {
const localTime = new Date(now.getTime())
localTime.setHours(now.getUTCHours() + zone.offset)
const hours = localTime.getHours().toString().padStart(2, "0")
const minutes = localTime.getMinutes().toString().padStart(2, "0")
const seconds = localTime.getSeconds().toString().padStart(2, "0")
times[zone.name] = `${hours}:${minutes}:${seconds}`
})
setCurrentTimes(times)
}
// Update immediately and then every second
updateTimes()
const interval = setInterval(updateTimes, 1000)
return () => clearInterval(interval)
}, [])
return (
<>
{TIME_ZONES.map((zone) => (
<Marker key={zone.name} longitude={zone.longitude} latitude={zone.latitude}>
<div className="relative group">
<div className="absolute -translate-x-1/2 -translate-y-full mb-2 pointer-events-none">
<div className="bg-black/80 text-white px-2 py-1 rounded-md text-xs font-mono">
<div className="text-center font-bold">{zone.name}</div>
<div className="digital-clock">{currentTimes[zone.name] || "00:00:00"}</div>
<div className="text-center text-xs text-gray-300">GMT+{zone.offset}</div>
</div>
</div>
</div>
</Marker>
))}
</>
)
}

View File

@ -181,3 +181,39 @@
} }
/* Digital clock styling */
.digital-clock {
font-family: monospace;
font-size: 1rem;
font-weight: bold;
color: #ffb700;
background-color: #000;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
border: 1px solid #333;
text-align: center;
letter-spacing: 0.05rem;
box-shadow: 0 0 5px rgba(255, 183, 0, 0.5);
}
/* Time zone markers */
.time-zone-marker {
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 0.5rem;
border-radius: 0.25rem;
border: 1px solid #333;
font-family: monospace;
}
.time-zone-marker .zone-name {
font-weight: bold;
text-align: center;
margin-bottom: 0.25rem;
}
.time-zone-marker .zone-offset {
font-size: 0.75rem;
text-align: center;
color: #ccc;
}