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:
parent
609a9c1327
commit
da94277f4d
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue