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 {
@ -98,8 +99,8 @@ export default function Layers({
// Reset selected state // Reset selected state
selectedDistrictRef.current = null selectedDistrictRef.current = null
setSelectedDistrict(null) setSelectedDistrict(null)
setSelectedIncident(null) setSelectedIncident(null)
setFocusedDistrictId(null) setFocusedDistrictId(null)
// Reset map view/camera // Reset map view/camera
if (map) { if (map) {
@ -111,20 +112,20 @@ export default function Layers({
easing: (t) => t * (2 - t), // easeOutQuad easing: (t) => t * (2 - t), // easeOutQuad
}) })
// Show all clusters again when closing popup // Show all clusters again when closing popup
if (map.getLayer("clusters")) { if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "visible") map.getMap().setLayoutProperty("clusters", "visibility", "visible")
} }
if (map.getLayer("unclustered-point")) { if (map.getLayer("unclustered-point")) {
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible") map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
} }
// Update fill color for all districts // Update fill color for all districts
if (map.getLayer("district-fill")) { if (map.getLayer("district-fill")) {
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict) const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression as any) map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
} }
} }
}, [map, crimeDataByDistrict]) }, [map, crimeDataByDistrict])
// Handle district popup close specifically // Handle district popup close specifically
@ -169,9 +170,9 @@ export default function Layers({
} }
if (map.getLayer("unclustered-point")) { if (map.getLayer("unclustered-point")) {
map.getMap().setLayoutProperty("unclustered-point", "visibility", "none") map.getMap().setLayoutProperty("unclustered-point", "visibility", "none")
} }
} }
}, },
[map], [map],
) )
@ -211,88 +212,88 @@ export default function Layers({
const customEvent = e as CustomEvent const customEvent = e as CustomEvent
console.log("Received incident_click event in layers:", customEvent.detail) console.log("Received incident_click event in layers:", customEvent.detail)
// Enhanced error checking // Enhanced error checking
if (!customEvent.detail) { if (!customEvent.detail) {
console.error("Empty incident click event data") console.error("Empty incident click event data")
return return
} }
// Allow for different property names in the event data // Allow for different property names in the event data
const incidentId = customEvent.detail.id || customEvent.detail.incidentId || customEvent.detail.incident_id const incidentId = customEvent.detail.id || customEvent.detail.incidentId || customEvent.detail.incident_id
if (!incidentId) { if (!incidentId) {
console.error("No incident ID found in event data:", customEvent.detail) console.error("No incident ID found in event data:", customEvent.detail)
return return
} }
console.log("Looking for incident with ID:", incidentId) console.log("Looking for incident with ID:", incidentId)
// Improved incident finding // Improved incident finding
let foundIncident: ICrimeIncident | undefined let foundIncident: ICrimeIncident | undefined
// First try to use the data directly from the event if it has all needed properties // First try to use the data directly from the event if it has all needed properties
if ( if (
customEvent.detail.latitude !== undefined && customEvent.detail.latitude !== undefined &&
customEvent.detail.longitude !== undefined && customEvent.detail.longitude !== undefined &&
customEvent.detail.category !== undefined customEvent.detail.category !== undefined
) { ) {
foundIncident = { foundIncident = {
id: incidentId, id: incidentId,
district: customEvent.detail.district, district: customEvent.detail.district,
category: customEvent.detail.category, category: customEvent.detail.category,
type_category: customEvent.detail.type, type_category: customEvent.detail.type,
description: customEvent.detail.description, description: customEvent.detail.description,
status: customEvent.detail.status || "Unknown", status: customEvent.detail.status || "Unknown",
timestamp: customEvent.detail.timestamp ? new Date(customEvent.detail.timestamp) : undefined, timestamp: customEvent.detail.timestamp ? new Date(customEvent.detail.timestamp) : undefined,
latitude: customEvent.detail.latitude, latitude: customEvent.detail.latitude,
longitude: customEvent.detail.longitude, longitude: customEvent.detail.longitude,
address: customEvent.detail.address, address: customEvent.detail.address,
} }
} else { } else {
// Otherwise search through the crimes data // Otherwise search through the crimes data
for (const crime of crimes) { for (const crime of crimes) {
for (const incident of crime.crime_incidents) { for (const incident of crime.crime_incidents) {
if (incident.id === incidentId || incident.id?.toString() === incidentId?.toString()) { if (incident.id === incidentId || incident.id?.toString() === incidentId?.toString()) {
console.log("Found matching incident:", incident) console.log("Found matching incident:", incident)
foundIncident = { foundIncident = {
id: incident.id, id: incident.id,
district: crime.districts.name, district: crime.districts.name,
description: incident.description, description: incident.description,
status: incident.status || "unknown", status: incident.status || "unknown",
timestamp: incident.timestamp, timestamp: incident.timestamp,
category: incident.crime_categories.name, category: incident.crime_categories.name,
type_category: incident.crime_categories.type, type_category: incident.crime_categories.type,
address: incident.locations.address, address: incident.locations.address,
latitude: incident.locations.latitude, latitude: incident.locations.latitude,
longitude: incident.locations.longitude, longitude: incident.locations.longitude,
} }
break break
} }
} }
if (foundIncident) break if (foundIncident) break
} }
} }
if (!foundIncident) { if (!foundIncident) {
console.error("Could not find incident with ID:", incidentId) console.error("Could not find incident with ID:", incidentId)
return return
} }
if (!foundIncident.latitude || !foundIncident.longitude) { if (!foundIncident.latitude || !foundIncident.longitude) {
console.error("Found incident has invalid coordinates:", foundIncident) console.error("Found incident has invalid coordinates:", foundIncident)
return return
} }
console.log("Setting selected incident:", foundIncident) console.log("Setting selected incident:", foundIncident)
// Clear any existing district selection first // Clear any existing district selection first
setSelectedDistrict(null) setSelectedDistrict(null)
selectedDistrictRef.current = null selectedDistrictRef.current = null
setFocusedDistrictId(null) setFocusedDistrictId(null)
// Set the selected incident // Set the selected incident
setSelectedIncident(foundIncident) setSelectedIncident(foundIncident)
} }
// Add event listeners to both the map canvas and document // Add event listeners to both the map canvas and document
mapboxMap.getCanvas().addEventListener("incident_click", handleIncidentClickEvent as EventListener) mapboxMap.getCanvas().addEventListener("incident_click", handleIncidentClickEvent as EventListener)
@ -378,18 +379,18 @@ export default function Layers({
selectedDistrictRef.current = updatedDistrict selectedDistrictRef.current = updatedDistrict
setSelectedDistrict((prevDistrict) => { setSelectedDistrict((prevDistrict) => {
if ( if (
prevDistrict?.id === updatedDistrict.id && prevDistrict?.id === updatedDistrict.id &&
prevDistrict?.selectedYear === updatedDistrict.selectedYear && prevDistrict?.selectedYear === updatedDistrict.selectedYear &&
prevDistrict?.selectedMonth === updatedDistrict.selectedMonth prevDistrict?.selectedMonth === updatedDistrict.selectedMonth
) { ) {
return prevDistrict return prevDistrict
} }
return updatedDistrict return updatedDistrict
}) })
} }
} }
}, [crimes, filterCategory, year, month, crimeDataByDistrict]) }, [crimes, filterCategory, year, month, crimeDataByDistrict])
// Make sure we have a defined handler for setFocusedDistrictId // 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 */} {/* 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
center: [number, number] districtName: string
}>() incidents: Array<{ timestamp: Date; category: string }>
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,17 +76,17 @@ 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,
}) })
} }
}) })
}) })
// 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, earliestTime: format(avgTimeInfo.earliest, "p"),
// Additional statistics latestTime: format(avgTimeInfo.latest, "p"),
earliestTime: format(avgTimeInfo.earliest, 'p'), mostFrequentHour: avgTimeInfo.mostFrequentHour,
latestTime: format(avgTimeInfo.latest, 'p'), categoryCounts: group.incidents.reduce(
mostFrequentHour: avgTimeInfo.mostFrequentHour, (acc, inc) => {
// Group incidents by category for the popup
categoryCounts: group.incidents.reduce((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({
center: districtData.center,
zoom: 12,
duration: 1000,
pitch: 45,
bearing: 0,
})
}
// Create HTML content for popup // Set the selected district for popup
const categoriesHtml = Object.entries(districtData.categoryCounts) setSelectedDistrict(districtData)
.sort(([, countA], [, countB]) => countB - countA) },
.slice(0, 5) // Top 5 categories [map, districtTimeData],
.map(([category, count]) => )
`<div class="flex justify-between mb-1">
<span class="text-xs">${category}</span>
<span class="text-xs font-semibold">${count}</span>
</div>`
).join('')
// Create popup // Handle popup close
const newPopup = new mapboxgl.Popup({ closeButton: true, closeOnClick: false }) const handleClosePopup = useCallback(() => {
.setLngLat(feature.geometry.type === 'Point' ? (feature.geometry as GeoJSON.Point).coordinates as [number, number] : [0, 0]) setSelectedDistrict(null)
.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"> // Add an effect to hide other layers when timeline is active
<div class="flex justify-between mb-1"> useEffect(() => {
<span>Earliest incident:</span> if (!map || !visible) return
<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"> // Hide incident markers when timeline mode is activated
<div class="text-xs font-medium mb-1">Top incident types:</div> if (map.getLayer("unclustered-point")) {
${categoriesHtml} map.setLayoutProperty("unclustered-point", "visibility", "none")
</div> }
</div>
`)
.addTo(map)
// Store popup reference // Hide clusters when timeline mode is activated
setPopup(newPopup) if (map.getLayer("clusters")) {
setSelectedDistrict(props.id) map.setLayoutProperty("clusters", "visibility", "none")
}
// Remove popup when closed if (map.getLayer("cluster-count")) {
newPopup.on('close', () => { map.setLayoutProperty("cluster-count", "visibility", "none")
setPopup(null)
setSelectedDistrict(null)
})
} }
// 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)
// Remove popup if it exists
if (popup) {
popup.remove()
setPopup(null)
}
} }
} }
}, [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(() => { 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}> <>
{/* Time marker circles */} <Source id="timeline-data" type="geojson" data={timelineGeoJSON}>
<Layer {/* Digital clock background */}
id="timeline-markers" <Layer
type="circle" id="timeline-markers-bg"
paint={{ type="circle"
'circle-color': [ paint={{
'match', "circle-color": [
['get', 'timeOfDay'], "match",
'morning', '#FFEB3B', ["get", "timeOfDay"],
'afternoon', '#FF9800', "morning",
'evening', '#3F51B5', "#FFEB3B",
'night', '#263238', "afternoon",
'#4CAF50' // Default color "#FF9800",
], "evening",
'circle-radius': 12, "#3F51B5",
'circle-stroke-width': 2, "night",
'circle-stroke-color': '#ffffff', "#263238",
'circle-opacity': 0.9 "#4CAF50", // Default color
}} ],
/> "circle-radius": 18,
"circle-stroke-width": 2,
"circle-stroke-color": "#000000",
"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
/> ],
</Source> "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" "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,248 +96,368 @@ 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,
properties: { properties: {
id: incident.id, id: incident.id,
description: incident.description || "No description", description: incident.description || "No description",
category: incident.crime_categories.name, category: incident.crime_categories.name,
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)
type: "FeatureCollection" as const, return {
features: [] type: "FeatureCollection" as const,
} 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)) {
districtUnitsMap.set(unit.district_id, []) districtUnitsMap.set(unit.district_id, [])
} }
districtUnitsMap.get(unit.district_id)!.push(unit) districtUnitsMap.get(unit.district_id)!.push(unit)
}) })
// 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({
type: "Feature" as const, type: "Feature" as const,
properties: { properties: {
unit_id: unit.code_unit, unit_id: unit.code_unit,
unit_name: unit.name, unit_name: unit.name,
incident_id: incident.id, incident_id: incident.id,
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],
] ],
} },
})
})
}) })
})
}) })
})
return { return {
type: "FeatureCollection" as const, type: "FeatureCollection" as const,
features: lineFeatures features: lineFeatures,
} }
}, [unitsData, crimes, filterCategory, categoryColorMap]) }, [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(() => { useEffect(() => {
if (!map || !visible) return 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 // 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>
@ -405,12 +520,12 @@ export default function UnitsLayer({
id="units-connection-lines" id="units-connection-lines"
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;
}