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")) {
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
}
if (map.getLayer("cluster-count")) {
map.setLayoutProperty(
"cluster-count",

View File

@ -20,6 +20,7 @@ import type { IUnits } from "@/app/_utils/types/units"
import UnitsLayer from "./units-layer"
import DistrictFillLineLayer from "./district-layer"
import CrimePopup from "../pop-up/crime-popup"
import TimeZonesDisplay from "../timezone"
// Interface for crime incident
interface ICrimeIncident {
@ -98,8 +99,8 @@ export default function Layers({
// Reset selected state
selectedDistrictRef.current = null
setSelectedDistrict(null)
setSelectedIncident(null)
setFocusedDistrictId(null)
setSelectedIncident(null)
setFocusedDistrictId(null)
// Reset map view/camera
if (map) {
@ -111,20 +112,20 @@ export default function Layers({
easing: (t) => t * (2 - t), // easeOutQuad
})
// Show all clusters again when closing popup
if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
}
// Show all clusters again when closing popup
if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
}
if (map.getLayer("unclustered-point")) {
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
}
// Update fill color for all districts
if (map.getLayer("district-fill")) {
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
}
}
// Update fill color for all districts
if (map.getLayer("district-fill")) {
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
}
}
}, [map, crimeDataByDistrict])
// Handle district popup close specifically
@ -169,9 +170,9 @@ export default function Layers({
}
if (map.getLayer("unclustered-point")) {
map.getMap().setLayoutProperty("unclustered-point", "visibility", "none")
}
}
},
}
}
},
[map],
)
@ -211,88 +212,88 @@ export default function Layers({
const customEvent = e as CustomEvent
console.log("Received incident_click event in layers:", customEvent.detail)
// Enhanced error checking
if (!customEvent.detail) {
console.error("Empty incident click event data")
return
}
// Enhanced error checking
if (!customEvent.detail) {
console.error("Empty incident click event data")
return
}
// Allow for different property names in the event data
const incidentId = customEvent.detail.id || customEvent.detail.incidentId || customEvent.detail.incident_id
// Allow for different property names in the event data
const incidentId = customEvent.detail.id || customEvent.detail.incidentId || customEvent.detail.incident_id
if (!incidentId) {
console.error("No incident ID found in event data:", customEvent.detail)
return
}
if (!incidentId) {
console.error("No incident ID found in event data:", customEvent.detail)
return
}
console.log("Looking for incident with ID:", incidentId)
console.log("Looking for incident with ID:", incidentId)
// Improved incident finding
let foundIncident: ICrimeIncident | undefined
// Improved incident finding
let foundIncident: ICrimeIncident | undefined
// First try to use the data directly from the event if it has all needed properties
if (
customEvent.detail.latitude !== undefined &&
customEvent.detail.longitude !== undefined &&
customEvent.detail.category !== undefined
) {
foundIncident = {
id: incidentId,
district: customEvent.detail.district,
category: customEvent.detail.category,
type_category: customEvent.detail.type,
description: customEvent.detail.description,
status: customEvent.detail.status || "Unknown",
timestamp: customEvent.detail.timestamp ? new Date(customEvent.detail.timestamp) : undefined,
latitude: customEvent.detail.latitude,
longitude: customEvent.detail.longitude,
address: customEvent.detail.address,
}
} else {
// Otherwise search through the crimes data
for (const crime of crimes) {
for (const incident of crime.crime_incidents) {
if (incident.id === incidentId || incident.id?.toString() === incidentId?.toString()) {
console.log("Found matching incident:", incident)
foundIncident = {
id: incident.id,
district: crime.districts.name,
description: incident.description,
status: incident.status || "unknown",
timestamp: incident.timestamp,
category: incident.crime_categories.name,
type_category: incident.crime_categories.type,
address: incident.locations.address,
latitude: incident.locations.latitude,
longitude: incident.locations.longitude,
}
break
}
}
if (foundIncident) break
}
}
// First try to use the data directly from the event if it has all needed properties
if (
customEvent.detail.latitude !== undefined &&
customEvent.detail.longitude !== undefined &&
customEvent.detail.category !== undefined
) {
foundIncident = {
id: incidentId,
district: customEvent.detail.district,
category: customEvent.detail.category,
type_category: customEvent.detail.type,
description: customEvent.detail.description,
status: customEvent.detail.status || "Unknown",
timestamp: customEvent.detail.timestamp ? new Date(customEvent.detail.timestamp) : undefined,
latitude: customEvent.detail.latitude,
longitude: customEvent.detail.longitude,
address: customEvent.detail.address,
}
} else {
// Otherwise search through the crimes data
for (const crime of crimes) {
for (const incident of crime.crime_incidents) {
if (incident.id === incidentId || incident.id?.toString() === incidentId?.toString()) {
console.log("Found matching incident:", incident)
foundIncident = {
id: incident.id,
district: crime.districts.name,
description: incident.description,
status: incident.status || "unknown",
timestamp: incident.timestamp,
category: incident.crime_categories.name,
type_category: incident.crime_categories.type,
address: incident.locations.address,
latitude: incident.locations.latitude,
longitude: incident.locations.longitude,
}
break
}
}
if (foundIncident) break
}
}
if (!foundIncident) {
console.error("Could not find incident with ID:", incidentId)
return
}
if (!foundIncident) {
console.error("Could not find incident with ID:", incidentId)
return
}
if (!foundIncident.latitude || !foundIncident.longitude) {
console.error("Found incident has invalid coordinates:", foundIncident)
return
}
if (!foundIncident.latitude || !foundIncident.longitude) {
console.error("Found incident has invalid coordinates:", foundIncident)
return
}
console.log("Setting selected incident:", foundIncident)
console.log("Setting selected incident:", foundIncident)
// Clear any existing district selection first
setSelectedDistrict(null)
selectedDistrictRef.current = null
setFocusedDistrictId(null)
// Clear any existing district selection first
setSelectedDistrict(null)
selectedDistrictRef.current = null
setFocusedDistrictId(null)
// Set the selected incident
setSelectedIncident(foundIncident)
}
// Set the selected incident
setSelectedIncident(foundIncident)
}
// Add event listeners to both the map canvas and document
mapboxMap.getCanvas().addEventListener("incident_click", handleIncidentClickEvent as EventListener)
@ -378,18 +379,18 @@ export default function Layers({
selectedDistrictRef.current = updatedDistrict
setSelectedDistrict((prevDistrict) => {
if (
prevDistrict?.id === updatedDistrict.id &&
prevDistrict?.selectedYear === updatedDistrict.selectedYear &&
prevDistrict?.selectedMonth === updatedDistrict.selectedMonth
) {
return prevDistrict
}
return updatedDistrict
})
}
}
setSelectedDistrict((prevDistrict) => {
if (
prevDistrict?.id === updatedDistrict.id &&
prevDistrict?.selectedYear === updatedDistrict.selectedYear &&
prevDistrict?.selectedMonth === updatedDistrict.selectedMonth
) {
return prevDistrict
}
return updatedDistrict
})
}
}
}, [crimes, filterCategory, year, month, crimeDataByDistrict])
// Make sure we have a defined handler for setFocusedDistrictId
@ -508,6 +509,9 @@ export default function Layers({
</>
)}
{/* Timeline Layer - only show if active control is timeline */}
<TimeZonesDisplay />
{/* Incident Popup */}
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<CrimePopup

View File

@ -1,11 +1,13 @@
"use client"
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState, useCallback } 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'
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[]
@ -14,7 +16,7 @@ interface TimelineLayerProps {
filterCategory: string | "all"
visible?: boolean
map?: mapboxgl.Map | null
useAllData?: boolean // New prop to use all data
useAllData?: boolean
}
export default function TimelineLayer({
@ -24,31 +26,32 @@ export default function TimelineLayer({
filterCategory,
visible = false,
map,
useAllData = false // Default to false
useAllData = false,
}: 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)
// 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]
}>()
const districtGroups = new Map<
string,
{
districtId: string
districtName: string
incidents: Array<{ timestamp: Date; category: string }>
center: [number, number]
}
>()
crimes.forEach(crime => {
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 centerIncident = crime.crime_incidents.find((inc) => inc.locations?.latitude && inc.locations?.longitude)
const center: [number, number] = centerIncident
? [centerIncident.locations.longitude, centerIncident.locations.latitude]
@ -58,12 +61,12 @@ export default function TimelineLayer({
districtId: crime.district_id,
districtName: crime.districts.name,
incidents: [],
center
})
center,
})
}
// Filter incidents appropriately before adding
crime.crime_incidents.forEach(incident => {
crime.crime_incidents.forEach((incident) => {
// Skip invalid incidents
if (!incident.timestamp) return
if (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) return
@ -73,17 +76,17 @@ export default function TimelineLayer({
if (group) {
group.incidents.push({
timestamp: new Date(incident.timestamp),
category: incident.crime_categories.name
})
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))
.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,
@ -94,31 +97,28 @@ export default function TimelineLayer({
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) => {
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>)
},
{} 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
}, [crimes, filterCategory, useAllData, year, month])
}, [crimes, filterCategory, year, month])
// Convert processed data to GeoJSON for display
const timelineGeoJSON = useMemo(() => {
return {
type: "FeatureCollection" as const,
features: districtTimeData.map(district => ({
features: districtTimeData.map((district) => ({
type: "Feature" as const,
properties: {
id: district.id,
@ -126,56 +126,21 @@ export default function TimelineLayer({
avgTime: district.formattedTime,
timeDescription: district.timeDescription,
totalIncidents: district.totalIncidents,
timeOfDay: district.timeOfDay
timeOfDay: district.timeOfDay,
hour: district.avgHour,
minute: district.avgMinute,
},
geometry: {
type: "Point" as const,
coordinates: district.center
}
}))
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
}
}
// 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[] }) => {
// 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]
@ -183,155 +148,150 @@ export default function TimelineLayer({
if (!props) return
// 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
// Remove existing popup if any
if (popup) popup.remove()
// Fly to the location
if (map) {
map.flyTo({
center: districtData.center,
zoom: 12,
duration: 1000,
pitch: 45,
bearing: 0,
})
}
// 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('')
// Set the selected district for popup
setSelectedDistrict(districtData)
},
[map, districtTimeData],
)
// 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)
// Handle popup close
const handleClosePopup = useCallback(() => {
setSelectedDistrict(null)
}, [])
// Store popup reference
setPopup(newPopup)
setSelectedDistrict(props.id)
// Add an effect to hide other layers when timeline is active
useEffect(() => {
if (!map || !visible) return
// Remove popup when closed
newPopup.on('close', () => {
setPopup(null)
setSelectedDistrict(null)
})
// 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'
if (map) map.getCanvas().style.cursor = "pointer"
}
const handleMouseLeave = () => {
if (map) map.getCanvas().style.cursor = ''
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)
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', 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.off("click", "timeline-markers", handleMarkerClick)
map.off("mouseenter", "timeline-markers", handleMouseEnter)
map.off("mouseleave", "timeline-markers", handleMouseLeave)
}
}
}, [map, visible, districtTimeData, popup])
}, [map, visible, handleMarkerClick])
// Clean up popup on unmount or when visibility changes
// Clean up on unmount or when visibility changes
useEffect(() => {
if (!visible && popup) {
popup.remove()
setPopup(null)
if (!visible) {
setSelectedDistrict(null)
}
}, [visible, popup])
}, [visible])
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
}}
/>
<>
<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,
}}
/>
{/* 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>
{/* 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}
/>
)}
</>
)
}

View File

@ -1,7 +1,7 @@
"use client"
import type { IUnclusteredPointLayerProps } from "@/app/_utils/types/map"
import { useEffect, useCallback } from "react"
import { useEffect, useCallback, useRef } from "react"
export default function UnclusteredPointLayer({
visible = true,
@ -10,6 +10,9 @@ export default function UnclusteredPointLayer({
filterCategory = "all",
focusedDistrictId,
}: IUnclusteredPointLayerProps) {
// Add a ref to track if we're currently interacting with a marker
const isInteractingWithMarker = useRef(false);
const handleIncidentClick = useCallback(
(e: any) => {
if (!map) return
@ -17,6 +20,9 @@ export default function UnclusteredPointLayer({
const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] })
if (!features || features.length === 0) return
// Set flag to indicate we're interacting with a marker
isInteractingWithMarker.current = true;
const incident = features[0]
if (!incident.properties) return
@ -37,13 +43,18 @@ export default function UnclusteredPointLayer({
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
map.flyTo({
center: [incidentDetails.longitude, incidentDetails.latitude],
zoom: 15,
bearing: 0,
pitch: 45,
duration: 1000,
duration: 2000,
})
// 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
map.getCanvas().dispatchEvent(customEvent)
document.dispatchEvent(customEvent)
// Reset the flag after a delay to allow the event to process
setTimeout(() => {
isInteractingWithMarker.current = false;
}, 500);
},
[map],
)
@ -130,9 +146,10 @@ export default function UnclusteredPointLayer({
"circle-stroke-width": 1,
"circle-stroke-color": "#fff",
},
// layout: {
// visibility: focusedDistrictId ? "visible" : "visible",
// },
layout: {
// Only hide markers if a district is focused AND we're not interacting with a marker
visibility: focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible",
},
},
firstSymbolId,
)
@ -145,8 +162,9 @@ export default function UnclusteredPointLayer({
map.getCanvas().style.cursor = ""
})
} else {
// Update visibility based on focused district
map.setLayoutProperty("unclustered-point", "visibility", focusedDistrictId ? "none" : "visible")
// Update visibility based on focused district, but keep visible when interacting with markers
const newVisibility = focusedDistrictId && !isInteractingWithMarker.current ? "none" : "visible";
map.setLayoutProperty("unclustered-point", "visibility", newVisibility);
}
// 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
if (map.isStyleLoaded()) {
setupLayerAndSource()

View File

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