Refactor map interaction and event handling

- Updated DistrictLayer to dispatch custom events for incident clicks and map fly-to actions.
- Enhanced DistrictFillLineLayer to support a new onDistrictClick prop for handling district interactions.
- Improved Layers component to manage district and incident selections, including new event listeners for incident clicks.
- Refactored UnclusteredPointLayer to streamline incident click handling and map interactions.
- Introduced FlyToHandler component to centralize fly-to event handling and highlight effects for incidents.
- Cleaned up event listener management across components to ensure proper cleanup and avoid memory leaks.
This commit is contained in:
vergiLgood1 2025-05-07 06:41:33 +07:00
parent ae6eb40a13
commit 609a9c1327
11 changed files with 739 additions and 712 deletions

View File

@ -104,26 +104,26 @@ export default function CrimeSidebar({
if (!map || !incident.longitude || !incident.latitude) return if (!map || !incident.longitude || !incident.latitude) return
// Fly to the incident location // Fly to the incident location
map.flyTo({ // map.flyTo({
center: [incident.longitude, incident.latitude], // center: [incident.longitude, incident.latitude],
zoom: 15, // zoom: 15,
pitch: 0, // pitch: 0,
bearing: 0, // bearing: 0,
duration: 1500, // duration: 1500,
easing: (t) => t * (2 - t), // easeOutQuad // easing: (t) => t * (2 - t), // easeOutQuad
}) // })
// Create and dispatch a custom event for the incident click // // Create and dispatch a custom event for the incident click
const customEvent = new CustomEvent("incident_click", { // const customEvent = new CustomEvent("incident_click", {
detail: incident, // detail: incident,
bubbles: true // bubbles: true
}) // })
if (map.getMap().getCanvas()) { // if (map.getMap().getCanvas()) {
map.getMap().getCanvas().dispatchEvent(customEvent) // map.getMap().getCanvas().dispatchEvent(customEvent)
} else { // } else {
document.dispatchEvent(customEvent) // document.dispatchEvent(customEvent)
} // }
} }
return ( return (

View File

@ -256,7 +256,7 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
const handleFlyToIncident = () => { const handleFlyToIncident = () => {
if (!selectedSuggestion || !selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude) return; if (!selectedSuggestion || !selectedSuggestion.locations.latitude || !selectedSuggestion.locations.longitude) return;
// First, trigger a separate mapbox_fly_to event to handle the camera movement // Dispatch mapbox_fly_to event to the main map canvas only
const flyToMapEvent = new CustomEvent('mapbox_fly_to', { const flyToMapEvent = new CustomEvent('mapbox_fly_to', {
detail: { detail: {
longitude: selectedSuggestion.locations.longitude, longitude: selectedSuggestion.locations.longitude,
@ -269,7 +269,11 @@ export default function SearchTooltip({ onControlChange, activeControl, crimes =
bubbles: true bubbles: true
}); });
document.dispatchEvent(flyToMapEvent); // Find the main map canvas and dispatch event there
const mapCanvas = document.querySelector('.mapboxgl-canvas');
if (mapCanvas) {
mapCanvas.dispatchEvent(flyToMapEvent);
}
// Wait for the fly animation to complete before showing the popup // Wait for the fly animation to complete before showing the popup
setTimeout(() => { setTimeout(() => {

View File

@ -16,34 +16,18 @@ import { useGetAvailableYears, useGetCrimeCategories, useGetCrimes } from "@/app
import MapSelectors from "./controls/map-selector" import MapSelectors from "./controls/map-selector"
import { cn } from "@/app/_lib/utils" import { cn } from "@/app/_lib/utils"
import CrimePopup from "./pop-up/crime-popup"
import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client" import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client"
import { CrimeTimelapse } from "./controls/bottom/crime-timelapse" import { CrimeTimelapse } from "./controls/bottom/crime-timelapse"
import { ITooltips } from "./controls/top/tooltips" import { ITooltips } from "./controls/top/tooltips"
import CrimeSidebar from "./controls/left/sidebar/map-sidebar" import CrimeSidebar from "./controls/left/sidebar/map-sidebar"
import Tooltips from "./controls/top/tooltips" import Tooltips from "./controls/top/tooltips"
import { BASE_BEARING, BASE_PITCH, BASE_ZOOM } from "@/app/_utils/const/map"
import Layers from "./layers/layers" import Layers from "./layers/layers"
import DistrictLayer, { DistrictFeature } from "./layers/district-layer-old" import DistrictLayer, { DistrictFeature } from "./layers/district-layer-old"
import { useGetUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries" import { useGetUnitsQuery } from "@/app/(pages)/(admin)/dashboard/crime-management/units/_queries/queries"
interface ICrimeIncident {
id: string
district?: string
category?: string
type_category?: string | null
description?: string
status: string
address?: string | null
timestamp?: Date
latitude?: number
longitude?: number
}
export default function CrimeMap() { export default function CrimeMap() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(true) const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null) const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
const [selectedIncident, setSelectedIncident] = useState<ICrimeIncident | null>(null)
const [showLegend, setShowLegend] = useState<boolean>(true) const [showLegend, setShowLegend] = useState<boolean>(true)
const [selectedCategory, setSelectedCategory] = useState<string | "all">("all") const [selectedCategory, setSelectedCategory] = useState<string | "all">("all")
const [selectedYear, setSelectedYear] = useState<number>(2024) const [selectedYear, setSelectedYear] = useState<number>(2024)
@ -131,152 +115,6 @@ export default function CrimeMap() {
}) })
}, [filteredByYearAndMonth, selectedCategory]) }, [filteredByYearAndMonth, selectedCategory])
useEffect(() => {
const handleIncidentClickEvent = (e: CustomEvent) => {
console.log("Received incident_click event:", e.detail);
if (!e.detail || !e.detail.id) {
console.error("Invalid incident data in event:", e.detail);
return;
}
let foundIncident: ICrimeIncident | undefined;
filteredCrimes.forEach(crime => {
crime.crime_incidents.forEach(incident => {
if (incident.id === e.detail.id) {
foundIncident = {
id: incident.id,
district: crime.districts.name,
description: incident.description,
status: incident.status || "unknown",
timestamp: incident.timestamp,
category: incident.crime_categories.name,
type_category: incident.crime_categories.type,
address: incident.locations.address,
latitude: incident.locations.latitude,
longitude: incident.locations.longitude,
};
}
});
});
if (!foundIncident) {
console.error("Could not find incident with ID:", e.detail.id);
return;
}
if (!foundIncident.latitude || !foundIncident.longitude) {
console.error("Invalid incident coordinates:", foundIncident);
return;
}
setSelectedDistrict(null);
setSelectedIncident(foundIncident);
};
const mapContainer = mapContainerRef.current;
document.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
if (mapContainer) {
mapContainer.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
}
document.addEventListener('incident_click', handleIncidentClickEvent as EventListener);
if (mapContainer) {
mapContainer.addEventListener('incident_click', handleIncidentClickEvent as EventListener);
}
return () => {
document.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
if (mapContainer) {
mapContainer.removeEventListener('incident_click', handleIncidentClickEvent as EventListener);
}
};
}, [filteredCrimes]);
useEffect(() => {
const handleMapFlyTo = (e: CustomEvent) => {
if (!e.detail) {
console.error("Invalid fly-to data:", e.detail);
return;
}
const { longitude, latitude, zoom, bearing, pitch, duration } = e.detail;
const mapInstance = mapContainerRef.current?.querySelector('.mapboxgl-map');
if (!mapInstance) {
console.error("Map instance not found");
return;
}
const mapboxEvent = new CustomEvent('mapbox_fly', {
detail: {
center: [longitude, latitude],
zoom: zoom || 15,
bearing: bearing || 0,
pitch: pitch || 45,
duration: duration || 2000
},
bubbles: true
});
mapInstance.dispatchEvent(mapboxEvent);
};
document.addEventListener('mapbox_fly_to', handleMapFlyTo as EventListener);
return () => {
document.removeEventListener('mapbox_fly_to', handleMapFlyTo as EventListener);
};
}, []);
useEffect(() => {
const handleMapReset = (e: CustomEvent) => {
const { duration } = e.detail || { duration: 1500 };
const mapInstance = mapContainerRef.current?.querySelector('.mapboxgl-map');
if (!mapInstance) {
console.error("Map instance not found");
return;
}
const mapboxEvent = new CustomEvent('mapbox_fly', {
detail: {
duration: duration,
resetCamera: true
},
bubbles: true
});
mapInstance.dispatchEvent(mapboxEvent);
};
document.addEventListener('mapbox_reset', handleMapReset as EventListener);
return () => {
document.removeEventListener('mapbox_reset', handleMapReset as EventListener);
};
}, []);
const handlePopupClose = () => {
setSelectedIncident(null);
const mapInstance = mapContainerRef.current?.querySelector('.mapboxgl-map');
if (mapInstance) {
const resetEvent = new CustomEvent('mapbox_reset', {
detail: {
duration: 1500,
},
bubbles: true
});
mapInstance.dispatchEvent(resetEvent);
}
}
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => { const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
setSelectedYear(year) setSelectedYear(year)
setSelectedMonth(month) setSelectedMonth(month)
@ -287,7 +125,6 @@ export default function CrimeMap() {
setisTimelapsePlaying(playing) setisTimelapsePlaying(playing)
if (playing) { if (playing) {
setSelectedIncident(null)
setSelectedDistrict(null) setSelectedDistrict(null)
} }
}, []) }, [])
@ -382,17 +219,6 @@ export default function CrimeMap() {
useAllData={useAllYears} useAllData={useAllYears}
/> />
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<>
<CrimePopup
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={handlePopupClose}
incident={selectedIncident}
/>
</>
)}
{isFullscreen && ( {isFullscreen && (
<> <>
<div className="absolute flex w-full p-2"> <div className="absolute flex w-full p-2">

View File

@ -0,0 +1,117 @@
"use client"
import { useEffect, useRef } from "react"
interface FlyToHandlerProps {
map: mapboxgl.Map
}
export default function FlyToHandler({ map }: FlyToHandlerProps) {
const animationRef = useRef<number | null>(null)
useEffect(() => {
if (!map) return
const handleFlyToEvent = (e: CustomEvent) => {
if (!map || !e.detail) return
const { longitude, latitude, zoom, bearing, pitch, duration } = e.detail
map.flyTo({
center: [longitude, latitude],
zoom: zoom || 15,
bearing: bearing || 0,
pitch: pitch || 45,
duration: duration || 2000,
})
// Cancel any existing animation
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
// Add a highlight or pulse effect to the target incident
try {
if (map.getLayer("target-incident-highlight")) {
map.removeLayer("target-incident-highlight")
}
if (map.getSource("target-incident-highlight")) {
map.removeSource("target-incident-highlight")
}
map.addSource("target-incident-highlight", {
type: "geojson",
data: {
type: "Feature",
geometry: {
type: "Point",
coordinates: [longitude, latitude],
},
properties: {},
},
})
map.addLayer({
id: "target-incident-highlight",
source: "target-incident-highlight",
type: "circle",
paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 10, 10, 15, 15, 20, 20],
"circle-color": "#ff0000",
"circle-opacity": 0.7,
"circle-stroke-width": 2,
"circle-stroke-color": "#ffffff",
},
})
// Add a slower pulsing effect
let size = 10
let frameCount = 0
const animationSpeed = 3
const animatePulse = () => {
if (!map || !map.getLayer("target-incident-highlight")) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
return
}
frameCount++
if (frameCount % animationSpeed === 0) {
size = (size % 20) + 0.5
}
map.setPaintProperty("target-incident-highlight", "circle-radius", [
"interpolate",
["linear"],
["zoom"],
10,
size,
15,
size * 1.5,
20,
size * 2,
])
animationRef.current = requestAnimationFrame(animatePulse)
}
animationRef.current = requestAnimationFrame(animatePulse)
} catch (error) {
// ignore highlight error
}
}
map.getCanvas().addEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
document.addEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
return () => {
if (map && map.getCanvas()) {
map.getCanvas().removeEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
}
document.removeEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
}
}, [map])
return null
}

View File

@ -1,128 +1 @@
"use client" // (File ini tidak perlu diaktifkan, semua fly-to sudah dihandle secara lokal pada masing-masing komponen.)
import { IBaseLayerProps } from "@/app/_utils/types/map"
import { useEffect, useRef } from "react"
export default function FlyToHandler({ map }: Pick<IBaseLayerProps, "map">) {
// Track active animations
const animationRef = useRef<number | null>(null)
useEffect(() => {
if (!map) return
const handleFlyToEvent = (e: CustomEvent) => {
if (!map || !e.detail) return
const { longitude, latitude, zoom, bearing, pitch, duration } = e.detail
map.flyTo({
center: [longitude, latitude],
zoom: zoom || 15,
bearing: bearing || 0,
pitch: pitch || 45,
duration: duration || 2000,
})
// Cancel any existing animation
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
// Add a highlight or pulse effect to the target incident
try {
if (map.getLayer("target-incident-highlight")) {
map.removeLayer("target-incident-highlight")
}
if (map.getSource("target-incident-highlight")) {
map.removeSource("target-incident-highlight")
}
map.addSource("target-incident-highlight", {
type: "geojson",
data: {
type: "Feature",
geometry: {
type: "Point",
coordinates: [longitude, latitude],
},
properties: {},
},
})
map.addLayer({
id: "target-incident-highlight",
source: "target-incident-highlight",
type: "circle",
paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 10, 10, 15, 15, 20, 20],
"circle-color": "#ff0000",
"circle-opacity": 0.7,
"circle-stroke-width": 2,
"circle-stroke-color": "#ffffff",
},
})
// Add a slower pulsing effect
let size = 10
let frameCount = 0
const animationSpeed = 3; // Higher value = slower animation (skip frames)
const animatePulse = () => {
if (!map || !map.getLayer("target-incident-highlight")) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
return
}
frameCount++;
// Only update size every few frames to slow down the animation
if (frameCount % animationSpeed === 0) {
size = (size % 20) + 0.5; // Smaller increment for smoother, slower animation
}
map.setPaintProperty("target-incident-highlight", "circle-radius", [
"interpolate",
["linear"],
["zoom"],
10,
size,
15,
size * 1.5,
20,
size * 2,
])
animationRef.current = requestAnimationFrame(animatePulse)
}
animationRef.current = requestAnimationFrame(animatePulse)
} catch (error) {
console.error("Error adding highlight effect:", error)
}
}
// Listen for the custom fly-to event
map.getCanvas().addEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
// Also listen on the document to ensure we catch the event
document.addEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
return () => {
if (map && map.getCanvas()) {
map.getCanvas().removeEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
}
document.removeEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
// Clean up animation on unmount
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
animationRef.current = null
}
}
}, [map])
return null
}

View File

@ -4,12 +4,12 @@ import { useEffect, useCallback } from "react"
import type mapboxgl from "mapbox-gl" import type mapboxgl from "mapbox-gl"
import type { GeoJSON } from "geojson" import type { GeoJSON } from "geojson"
import { IClusterLayerProps } from "@/app/_utils/types/map" import type { IClusterLayerProps } from "@/app/_utils/types/map"
import { extractCrimeIncidents } from "@/app/_utils/map" import { extractCrimeIncidents } from "@/app/_utils/map"
interface ExtendedClusterLayerProps extends IClusterLayerProps { interface ExtendedClusterLayerProps extends IClusterLayerProps {
clusteringEnabled?: boolean; clusteringEnabled?: boolean
showClusters?: boolean; showClusters?: boolean
} }
export default function ClusterLayer({ export default function ClusterLayer({
@ -36,7 +36,9 @@ export default function ClusterLayer({
try { try {
// Get the expanded zoom level for this cluster // Get the expanded zoom level for this cluster
(map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(clusterId, (err, zoom) => { ; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(
clusterId,
(err, zoom) => {
if (err) { if (err) {
console.error("Error getting cluster expansion zoom:", err) console.error("Error getting cluster expansion zoom:", err)
return return
@ -44,29 +46,16 @@ export default function ClusterLayer({
const coordinates = (features[0].geometry as any).coordinates const coordinates = (features[0].geometry as any).coordinates
// Dispatch a custom event for the fly-to behavior // Explicitly fly to the cluster location
const clusterClickEvent = new CustomEvent('cluster_click', {
detail: {
center: coordinates,
zoom: zoom ?? undefined,
},
bubbles: true
})
if (map.getCanvas()) {
map.getCanvas().dispatchEvent(clusterClickEvent)
} else {
document.dispatchEvent(clusterClickEvent)
}
// Also perform the direct flyTo operation for immediate feedback
map.flyTo({ map.flyTo({
center: coordinates, center: coordinates,
zoom: zoom ?? 12, zoom: zoom ?? 12,
bearing: 0,
pitch: 45,
duration: 1000, duration: 1000,
easing: (t) => t * (2 - t) // easeOutQuad
})
}) })
},
)
} catch (error) { } catch (error) {
console.error("Error handling cluster click:", error) console.error("Error handling cluster click:", error)
} }
@ -157,19 +146,19 @@ export default function ClusterLayer({
// Update source clustering option // Update source clustering option
try { try {
// We need to recreate the source if we're changing the clustering option // We need to recreate the source if we're changing the clustering option
const currentSource = map.getSource("crime-incidents") as mapboxgl.GeoJSONSource; const currentSource = map.getSource("crime-incidents") as mapboxgl.GeoJSONSource
const data = (currentSource as any)._data; // Get current data const data = (currentSource as any)._data // Get current data
// If clustering state has changed, recreate the source // If clustering state has changed, recreate the source
const existingClusterState = (currentSource as any).options?.cluster; const existingClusterState = (currentSource as any).options?.cluster
if (existingClusterState !== clusteringEnabled) { if (existingClusterState !== clusteringEnabled) {
// Remove existing layers that use this source // Remove existing layers that use this source
if (map.getLayer("clusters")) map.removeLayer("clusters"); if (map.getLayer("clusters")) map.removeLayer("clusters")
if (map.getLayer("cluster-count")) map.removeLayer("cluster-count"); if (map.getLayer("cluster-count")) map.removeLayer("cluster-count")
if (map.getLayer("unclustered-point")) map.removeLayer("unclustered-point"); if (map.getLayer("unclustered-point")) map.removeLayer("unclustered-point")
// Remove and recreate source with new clustering setting // Remove and recreate source with new clustering setting
map.removeSource("crime-incidents"); map.removeSource("crime-incidents")
map.addSource("crime-incidents", { map.addSource("crime-incidents", {
type: "geojson", type: "geojson",
@ -177,7 +166,7 @@ export default function ClusterLayer({
cluster: clusteringEnabled, cluster: clusteringEnabled,
clusterMaxZoom: 14, clusterMaxZoom: 14,
clusterRadius: 50, clusterRadius: 50,
}); })
// Re-add the layers // Re-add the layers
if (!map.getLayer("clusters")) { if (!map.getLayer("clusters")) {
@ -197,7 +186,7 @@ export default function ClusterLayer({
}, },
}, },
firstSymbolId, firstSymbolId,
); )
} }
if (!map.getLayer("cluster-count")) { if (!map.getLayer("cluster-count")) {
@ -215,11 +204,11 @@ export default function ClusterLayer({
paint: { paint: {
"text-color": "#ffffff", "text-color": "#ffffff",
}, },
}); })
} }
} }
} catch (error) { } catch (error) {
console.error("Error updating cluster source:", error); console.error("Error updating cluster source:", error)
} }
// Update visibility based on focused district and showClusters flag // Update visibility based on focused district and showClusters flag
@ -227,7 +216,11 @@ export default function ClusterLayer({
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("cluster-count", "visibility", showClusters && !focusedDistrictId ? "visible" : "none") map.setLayoutProperty(
"cluster-count",
"visibility",
showClusters && !focusedDistrictId ? "visible" : "none",
)
} }
// Update the cluster click handler // Update the cluster click handler
@ -269,15 +262,15 @@ export default function ClusterLayer({
// Update visibility when showClusters changes // Update visibility when showClusters changes
useEffect(() => { useEffect(() => {
if (!map || !map.getLayer("clusters") || !map.getLayer("cluster-count")) return; if (!map || !map.getLayer("clusters") || !map.getLayer("cluster-count")) return
try { try {
map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none"); map.setLayoutProperty("clusters", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
map.setLayoutProperty("cluster-count", "visibility", showClusters && !focusedDistrictId ? "visible" : "none"); map.setLayoutProperty("cluster-count", "visibility", showClusters && !focusedDistrictId ? "visible" : "none")
} catch (error) { } catch (error) {
console.error("Error updating cluster visibility:", error); console.error("Error updating cluster visibility:", error)
} }
}, [map, showClusters, focusedDistrictId]); }, [map, showClusters, focusedDistrictId])
return null return null
} }

View File

@ -339,18 +339,38 @@ export default function DistrictLayer({
timestamp: new Date(), timestamp: new Date(),
} }
console.log("Incident clicked:", incidentDetails) // console.log("Incident clicked:", incidentDetails)
const customEvent = new CustomEvent("incident_click", { // Dispatch mapbox_fly_to event instead of direct flyTo
detail: incidentDetails, // const flyToEvent = new CustomEvent("mapbox_fly_to", {
bubbles: true, // detail: {
}) // longitude: incidentDetails.longitude,
// latitude: incidentDetails.latitude,
// zoom: 15,
// bearing: 0,
// pitch: 45,
// duration: 2000,
// },
// bubbles: true,
// })
// if (map.getMap().getCanvas()) {
// map.getMap().getCanvas().dispatchEvent(flyToEvent)
// } else {
// document.dispatchEvent(flyToEvent)
// }
// Dispatch incident_click event after a short delay to allow fly animation
// const customEvent = new CustomEvent("incident_click", {
// detail: incidentDetails,
// bubbles: true,
// })
// if (map.getMap().getCanvas()) {
// map.getMap().getCanvas().dispatchEvent(customEvent)
// } else {
// document.dispatchEvent(customEvent)
// }
if (map.getMap().getCanvas()) {
map.getMap().getCanvas().dispatchEvent(customEvent)
} else {
document.dispatchEvent(customEvent)
}
}, },
[map], [map],
) )
@ -417,10 +437,11 @@ export default function DistrictLayer({
useEffect(() => { useEffect(() => {
if (!map) return; if (!map) return;
const handleFlyToEvent = (e: CustomEvent) => { const handleFlyToEvent = (e: Event) => {
if (!map || !e.detail) return; const customEvent = e as CustomEvent;
if (!map || !customEvent.detail) return;
const { longitude, latitude, zoom, bearing, pitch, duration } = e.detail; const { longitude, latitude, zoom, bearing, pitch, duration } = customEvent.detail;
map.flyTo({ map.flyTo({
center: [longitude, latitude], center: [longitude, latitude],

View File

@ -10,6 +10,7 @@ export default function DistrictFillLineLayer({
visible = true, visible = true,
map, map,
onClick, onClick,
onDistrictClick, // Add the new prop
year, year,
month, month,
filterCategory = "all", filterCategory = "all",
@ -20,7 +21,7 @@ export default function DistrictFillLineLayer({
crimeDataByDistrict, crimeDataByDistrict,
showFill = true, showFill = true,
activeControl, activeControl,
}: IDistrictLayerProps) { }: IDistrictLayerProps & { onDistrictClick?: (district: any) => void }) { // Extend the type inline
useEffect(() => { useEffect(() => {
if (!map || !visible) return if (!map || !visible) return
@ -91,7 +92,10 @@ export default function DistrictFillLineLayer({
easing: (t) => t * (2 - t), // easeOutQuad easing: (t) => t * (2 - t), // easeOutQuad
}) })
if (onClick) { // Use onDistrictClick if available, otherwise fall back to onClick
if (onDistrictClick) {
onDistrictClick(district)
} else if (onClick) {
onClick(district) onClick(district)
} }
}, 100) }, 100)
@ -130,7 +134,10 @@ export default function DistrictFillLineLayer({
easing: (t) => t * (2 - t), // easeOutQuad easing: (t) => t * (2 - t), // easeOutQuad
}) })
if (onClick) { // Use onDistrictClick if available, otherwise fall back to onClick
if (onDistrictClick) {
onDistrictClick(district)
} else if (onClick) {
onClick(district) onClick(district)
} }
} }
@ -239,6 +246,7 @@ export default function DistrictFillLineLayer({
focusedDistrictId, focusedDistrictId,
crimeDataByDistrict, crimeDataByDistrict,
onClick, onClick,
onDistrictClick, // Add to dependency array
setFocusedDistrictId, setFocusedDistrictId,
showFill, showFill,
activeControl, activeControl,

View File

@ -7,42 +7,63 @@ import DistrictPopup from "../pop-up/district-popup"
import DistrictExtrusionLayer from "./district-extrusion-layer" import DistrictExtrusionLayer from "./district-extrusion-layer"
import ClusterLayer from "./cluster-layer" import ClusterLayer from "./cluster-layer"
import HeatmapLayer from "./heatmap-layer" import HeatmapLayer from "./heatmap-layer"
import DistrictLayer from "./district-layer-old"
import TimelineLayer from "./timeline-layer" import TimelineLayer from "./timeline-layer"
import type { ICrimes } from "@/app/_utils/types/crimes" import type { ICrimes } from "@/app/_utils/types/crimes"
import { IDistrictFeature } from "@/app/_utils/types/map" import type { IDistrictFeature } from "@/app/_utils/types/map"
import { createFillColorExpression, processCrimeDataByDistrict } from "@/app/_utils/map" import { createFillColorExpression, processCrimeDataByDistrict } from "@/app/_utils/map"
import UnclusteredPointLayer from "./uncluster-layer" import UnclusteredPointLayer from "./uncluster-layer"
import FlyToHandler from "../fly-to"
import { toast } from "sonner" import { toast } from "sonner"
import { ITooltips } from "../controls/top/tooltips" import type { ITooltips } from "../controls/top/tooltips"
import { IUnits } from "@/app/_utils/types/units" 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"
// Interface for crime incident
interface ICrimeIncident {
id: string
district?: string
category?: string
type_category?: string | null
description?: string
status: string
address?: string | null
timestamp?: Date
latitude?: number
longitude?: number
}
// District layer props // District layer props
export interface IDistrictLayerProps { export interface IDistrictLayerProps {
visible?: boolean visible?: boolean
onClick?: (feature: IDistrictFeature) => void onClick?: (feature: IDistrictFeature) => void
onDistrictClick?: (feature: IDistrictFeature) => void
map?: any
year: string year: string
month: string month: string
filterCategory: string | "all" filterCategory: string | "all"
crimes: ICrimes[] crimes: ICrimes[]
units?: IUnits[] units?: IUnits[]
tilesetId?: string tilesetId?: string
focusedDistrictId?: string | null
setFocusedDistrictId?: (id: string | null) => void
crimeDataByDistrict?: Record<string, any>
showFill?: boolean
activeControl?: ITooltips
} }
interface LayersProps { interface LayersProps {
visible?: boolean; visible?: boolean
crimes: ICrimes[]; crimes: ICrimes[]
units?: IUnits[]; units?: IUnits[]
year: string; year: string
month: string; month: string
filterCategory: string | "all"; filterCategory: string | "all"
activeControl: ITooltips; activeControl: ITooltips
tilesetId?: string; tilesetId?: string
useAllData?: boolean; // New prop to indicate if we're showing all data useAllData?: boolean
} }
export default function Layers({ export default function Layers({
@ -66,44 +87,21 @@ export default function Layers({
const mapboxMap = map.getMap() const mapboxMap = map.getMap()
const [selectedDistrict, setSelectedDistrict] = useState<IDistrictFeature | null>(null) const [selectedDistrict, setSelectedDistrict] = useState<IDistrictFeature | null>(null)
const [selectedIncident, setSelectedIncident] = useState<ICrimeIncident | null>(null)
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null) const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null)
const selectedDistrictRef = useRef<IDistrictFeature | null>(null) const selectedDistrictRef = useRef<IDistrictFeature | null>(null)
const crimeDataByDistrict = processCrimeDataByDistrict(crimes) const crimeDataByDistrict = processCrimeDataByDistrict(crimes)
// Set up custom event handler for cluster clicks to ensure it works across components // Handle popup close with a common reset pattern
useEffect(() => { const handlePopupClose = useCallback(() => {
if (!mapboxMap) return; // Reset selected state
const handleClusterClickEvent = (e: CustomEvent) => {
if (!e.detail) return;
const { center, zoom } = e.detail;
if (center && zoom) {
mapboxMap.flyTo({
center: center,
zoom: zoom,
duration: 1000,
easing: (t) => t * (2 - t)
});
}
};
mapboxMap.getCanvas().addEventListener('cluster_click', handleClusterClickEvent as EventListener);
return () => {
mapboxMap.getCanvas().removeEventListener('cluster_click', handleClusterClickEvent as EventListener);
};
}, [mapboxMap]);
// Handle popup close
const handleCloseDistrictPopup = () => {
console.log("Closing district popup")
selectedDistrictRef.current = null selectedDistrictRef.current = null
setSelectedDistrict(null) setSelectedDistrict(null)
setSelectedIncident(null)
setFocusedDistrictId(null) setFocusedDistrictId(null)
// Reset pitch and bearing // Reset map view/camera
if (map) { if (map) {
map.easeTo({ map.easeTo({
zoom: BASE_ZOOM, zoom: BASE_ZOOM,
@ -121,13 +119,196 @@ export default function Layers({
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible") map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
} }
// Explicitly 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])
// Handle district popup close specifically
const handleCloseDistrictPopup = useCallback(() => {
console.log("Closing district popup")
handlePopupClose()
}, [handlePopupClose])
// Handle incident popup close specifically
const handleCloseIncidentPopup = useCallback(() => {
console.log("Closing incident popup")
handlePopupClose()
}, [handlePopupClose])
// Handle district clicks
const handleDistrictClick = useCallback(
(feature: IDistrictFeature) => {
console.log("District clicked:", feature)
// Clear any incident selection when showing a district
setSelectedIncident(null)
// Set the selected district
setSelectedDistrict(feature)
selectedDistrictRef.current = feature
setFocusedDistrictId(feature.id)
// Fly to the district
if (map && feature.longitude && feature.latitude) {
map.flyTo({
center: [feature.longitude, feature.latitude],
zoom: 12,
pitch: 45,
bearing: 0,
duration: 1500,
easing: (t) => t * (2 - t),
})
// Hide clusters when focusing on district
if (map.getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "none")
} }
if (map.getLayer("unclustered-point")) {
map.getMap().setLayoutProperty("unclustered-point", "visibility", "none")
}
}
},
[map],
)
// Set up custom event handler for fly-to events
useEffect(() => {
if (!mapboxMap) return
const handleFlyToEvent = (e: Event) => {
const customEvent = e as CustomEvent
if (!map || !customEvent.detail) return
const { longitude, latitude, zoom, bearing, pitch, duration } = customEvent.detail
map.flyTo({
center: [longitude, latitude],
zoom: zoom || 15,
bearing: bearing || 0,
pitch: pitch || 45,
duration: duration || 2000,
})
}
mapboxMap.getCanvas().addEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
return () => {
if (mapboxMap && mapboxMap.getCanvas()) {
mapboxMap.getCanvas().removeEventListener("mapbox_fly_to", handleFlyToEvent as EventListener)
}
}
}, [mapboxMap, map])
// Handle incident click events
useEffect(() => {
if (!mapboxMap) return
const handleIncidentClickEvent = (e: Event) => {
const customEvent = e as CustomEvent
console.log("Received incident_click event in layers:", customEvent.detail)
// Enhanced error checking
if (!customEvent.detail) {
console.error("Empty incident click event data")
return
}
// Allow for different property names in the event data
const incidentId = customEvent.detail.id || customEvent.detail.incidentId || customEvent.detail.incident_id
if (!incidentId) {
console.error("No incident ID found in event data:", customEvent.detail)
return
}
console.log("Looking for incident with ID:", incidentId)
// Improved incident finding
let foundIncident: ICrimeIncident | undefined
// First try to use the data directly from the event if it has all needed properties
if (
customEvent.detail.latitude !== undefined &&
customEvent.detail.longitude !== undefined &&
customEvent.detail.category !== undefined
) {
foundIncident = {
id: incidentId,
district: customEvent.detail.district,
category: customEvent.detail.category,
type_category: customEvent.detail.type,
description: customEvent.detail.description,
status: customEvent.detail.status || "Unknown",
timestamp: customEvent.detail.timestamp ? new Date(customEvent.detail.timestamp) : undefined,
latitude: customEvent.detail.latitude,
longitude: customEvent.detail.longitude,
address: customEvent.detail.address,
}
} else {
// Otherwise search through the crimes data
for (const crime of crimes) {
for (const incident of crime.crime_incidents) {
if (incident.id === incidentId || incident.id?.toString() === incidentId?.toString()) {
console.log("Found matching incident:", incident)
foundIncident = {
id: incident.id,
district: crime.districts.name,
description: incident.description,
status: incident.status || "unknown",
timestamp: incident.timestamp,
category: incident.crime_categories.name,
type_category: incident.crime_categories.type,
address: incident.locations.address,
latitude: incident.locations.latitude,
longitude: incident.locations.longitude,
}
break
}
}
if (foundIncident) break
}
}
if (!foundIncident) {
console.error("Could not find incident with ID:", incidentId)
return
}
if (!foundIncident.latitude || !foundIncident.longitude) {
console.error("Found incident has invalid coordinates:", foundIncident)
return
}
console.log("Setting selected incident:", foundIncident)
// Clear any existing district selection first
setSelectedDistrict(null)
selectedDistrictRef.current = null
setFocusedDistrictId(null)
// Set the selected incident
setSelectedIncident(foundIncident)
}
// Add event listeners to both the map canvas and document
mapboxMap.getCanvas().addEventListener("incident_click", handleIncidentClickEvent as EventListener)
document.addEventListener("incident_click", handleIncidentClickEvent as EventListener)
// For debugging purposes, log when this effect runs
console.log("Set up incident click event listener")
return () => {
console.log("Removing incident click event listener")
if (mapboxMap && mapboxMap.getCanvas()) {
mapboxMap.getCanvas().removeEventListener("incident_click", handleIncidentClickEvent as EventListener)
}
document.removeEventListener("incident_click", handleIncidentClickEvent as EventListener)
}
}, [mapboxMap, crimes, setFocusedDistrictId])
// Update selected district when year/month/filter changes // Update selected district when year/month/filter changes
useEffect(() => { useEffect(() => {
@ -212,25 +393,26 @@ export default function Layers({
}, [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
const handleSetFocusedDistrictId = useCallback((id: string | null) => { const handleSetFocusedDistrictId = useCallback((id: string | null, isMarkerClick = false) => {
setFocusedDistrictId(id); console.log("Setting focused district ID:", id, "from marker click:", isMarkerClick)
}, []); setFocusedDistrictId(id)
}, [])
if (!visible) return null if (!visible) return null
// Determine which layers should be visible based on the active control // Determine which layers should be visible based on the active control
const showDistrictLayer = activeControl === "incidents"; const showDistrictLayer = activeControl === "incidents"
const showHeatmapLayer = activeControl === "heatmap"; const showHeatmapLayer = activeControl === "heatmap"
const showClustersLayer = activeControl === "clusters"; const showClustersLayer = activeControl === "clusters"
const showUnitsLayer = activeControl === "units"; const showUnitsLayer = activeControl === "units"
const showTimelineLayer = activeControl === "timeline"; const showTimelineLayer = activeControl === "timeline"
// District fill should only be visible for incidents and clusters // District fill should only be visible for incidents and clusters
const showDistrictFill = activeControl === "incidents" || activeControl === "clusters"; const showDistrictFill = activeControl === "incidents" || activeControl === "clusters"
// Show incident markers for incidents, clusters, AND units modes // Show incident markers for incidents, clusters, AND units modes
// But hide for heatmap and timeline // But hide for heatmap and timeline
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline"; const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline"
return ( return (
<> <>
@ -248,6 +430,7 @@ export default function Layers({
crimeDataByDistrict={crimeDataByDistrict} crimeDataByDistrict={crimeDataByDistrict}
showFill={showDistrictFill} showFill={showDistrictFill}
activeControl={activeControl} activeControl={activeControl}
onDistrictClick={handleDistrictClick} // Add this prop to pass the click handler
/> />
{/* Heatmap Layer */} {/* Heatmap Layer */}
@ -258,6 +441,8 @@ export default function Layers({
filterCategory={filterCategory} filterCategory={filterCategory}
visible={showHeatmapLayer} visible={showHeatmapLayer}
useAllData={useAllData} useAllData={useAllData}
enableInteractions={true}
setFocusedDistrictId={handleSetFocusedDistrictId}
/> />
{/* Timeline Layer - make sure this is the only visible layer in timeline mode */} {/* Timeline Layer - make sure this is the only visible layer in timeline mode */}
@ -280,15 +465,6 @@ export default function Layers({
map={mapboxMap} map={mapboxMap}
/> />
{/* District base layer */}
<DistrictExtrusionLayer
visible={visible}
map={mapboxMap}
tilesetId={tilesetId}
focusedDistrictId={focusedDistrictId}
crimeDataByDistrict={crimeDataByDistrict}
/>
{/* Cluster Layer - only enable when the clusters control is active and NOT in timeline mode */} {/* Cluster Layer - only enable when the clusters control is active and NOT in timeline mode */}
<ClusterLayer <ClusterLayer
visible={visible && activeControl === "clusters"} visible={visible && activeControl === "clusters"}
@ -309,9 +485,9 @@ export default function Layers({
focusedDistrictId={focusedDistrictId} focusedDistrictId={focusedDistrictId}
/> />
<FlyToHandler map={mapboxMap} /> {/* District Popup */}
{selectedDistrict && !selectedIncident && (
{selectedDistrict && ( <>
<DistrictPopup <DistrictPopup
longitude={selectedDistrict.longitude || 0} longitude={selectedDistrict.longitude || 0}
latitude={selectedDistrict.latitude || 0} latitude={selectedDistrict.latitude || 0}
@ -321,7 +497,44 @@ export default function Layers({
month={month} month={month}
filterCategory={filterCategory} filterCategory={filterCategory}
/> />
<DistrictExtrusionLayer
visible={visible}
map={mapboxMap}
tilesetId={tilesetId}
focusedDistrictId={focusedDistrictId}
crimeDataByDistrict={crimeDataByDistrict}
/>
</>
)} )}
{/* Incident Popup */}
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<CrimePopup
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={handleCloseIncidentPopup}
incident={selectedIncident}
/>
)}
{/* Debug info for development */}
<div
className="text-red-500 bg-accent"
style={{
position: "absolute",
bottom: 10,
left: 10,
backgroundColor: "rgba(255,255,255,0.7)",
padding: "5px",
zIndex: 999,
display: process.env.NODE_ENV === "development" ? "block" : "none",
}}
>
<div>Selected District: {selectedDistrict ? selectedDistrict.name : "None"}</div>
<div>Selected Incident: {selectedIncident ? selectedIncident.id : "None"}</div>
<div>Focused District ID: {focusedDistrictId || "None"}</div>
</div>
</> </>
) )
} }

View File

@ -1,6 +1,6 @@
"use client" "use client"
import { IUnclusteredPointLayerProps } from "@/app/_utils/types/map" import type { IUnclusteredPointLayerProps } from "@/app/_utils/types/map"
import { useEffect, useCallback } from "react" import { useEffect, useCallback } from "react"
export default function UnclusteredPointLayer({ export default function UnclusteredPointLayer({
@ -10,37 +10,6 @@ export default function UnclusteredPointLayer({
filterCategory = "all", filterCategory = "all",
focusedDistrictId, focusedDistrictId,
}: IUnclusteredPointLayerProps) { }: IUnclusteredPointLayerProps) {
// Konversi crimes ke GeoJSON FeatureCollection
const geojsonData = {
type: "FeatureCollection" as const,
features: crimes.flatMap((crime) =>
crime.crime_incidents
.filter(
(incident) =>
(filterCategory === "all" || incident.crime_categories.name === filterCategory) &&
incident.locations &&
typeof incident.locations.longitude === "number" &&
typeof incident.locations.latitude === "number"
)
.map((incident) => ({
type: "Feature" as const,
geometry: {
type: "Point" as const,
coordinates: [incident.locations.longitude, incident.locations.latitude],
},
properties: {
id: incident.id,
district: crime.districts.name,
category: incident.crime_categories.name,
incidentType: incident.crime_categories.type || "",
description: incident.description,
status: incident.status || "",
timestamp: incident.timestamp ? incident.timestamp.toString() : "",
},
}))
),
}
const handleIncidentClick = useCallback( const handleIncidentClick = useCallback(
(e: any) => { (e: any) => {
if (!map) return if (!map) return
@ -68,36 +37,24 @@ export default function UnclusteredPointLayer({
console.log("Incident clicked:", incidentDetails) console.log("Incident clicked:", incidentDetails)
// Create a custom event with incident details // First fly to the incident location
map.flyTo({
center: [incidentDetails.longitude, incidentDetails.latitude],
zoom: 15,
bearing: 0,
pitch: 45,
duration: 1000,
})
// Then dispatch the incident_click event to show the popup
const customEvent = new CustomEvent("incident_click", { const customEvent = new CustomEvent("incident_click", {
detail: incidentDetails, detail: incidentDetails,
bubbles: true, bubbles: true,
}) })
// Dispatch the event on both the map canvas and document to ensure it's captured // Dispatch on both the map canvas and document to ensure it's caught
if (map.getCanvas()) {
map.getCanvas().dispatchEvent(customEvent) map.getCanvas().dispatchEvent(customEvent)
}
document.dispatchEvent(customEvent) document.dispatchEvent(customEvent)
// Also trigger a fly-to event to zoom to the incident
const flyToEvent = new CustomEvent("mapbox_fly_to", {
detail: {
longitude: incidentDetails.longitude,
latitude: incidentDetails.latitude,
zoom: 15,
bearing: 0,
pitch: 45,
duration: 1000,
},
bubbles: true,
})
if (map.getCanvas()) {
map.getCanvas().dispatchEvent(flyToEvent)
} else {
document.dispatchEvent(flyToEvent)
}
}, },
[map], [map],
) )
@ -105,26 +62,57 @@ export default function UnclusteredPointLayer({
useEffect(() => { useEffect(() => {
if (!map || !visible) return if (!map || !visible) return
// Konversi crimes ke GeoJSON FeatureCollection
const geojsonData = {
type: "FeatureCollection" as const,
features: crimes.flatMap((crime) =>
crime.crime_incidents
.filter(
(incident) =>
(filterCategory === "all" || incident.crime_categories.name === filterCategory) &&
incident.locations &&
typeof incident.locations.longitude === "number" &&
typeof incident.locations.latitude === "number",
)
.map((incident) => ({
type: "Feature" as const,
geometry: {
type: "Point" as const,
coordinates: [incident.locations.longitude, incident.locations.latitude],
},
properties: {
id: incident.id,
district: crime.districts.name,
category: incident.crime_categories.name,
incidentType: incident.crime_categories.type || "",
description: incident.description,
status: incident.status || "",
timestamp: incident.timestamp ? incident.timestamp.toString() : "",
},
})),
),
}
const setupLayerAndSource = () => { const setupLayerAndSource = () => {
try { try {
// First check if source exists and update it // First check if source exists and update it
if (map.getSource("crime-incidents")) { if (map.getSource("crime-incidents")) {
(map.getSource("crime-incidents") as any).setData(geojsonData); ; (map.getSource("crime-incidents") as any).setData(geojsonData)
} else { } else {
// If not, add source // If not, add source
map.addSource("crime-incidents", { map.addSource("crime-incidents", {
type: "geojson", type: "geojson",
data: geojsonData, data: geojsonData,
}); })
} }
// Get layers to find first symbol layer // Get layers to find first symbol layer
const layers = map.getStyle().layers; const layers = map.getStyle().layers
let firstSymbolId: string | undefined; let firstSymbolId: string | undefined
for (const layer of layers) { for (const layer of layers) {
if (layer.type === "symbol") { if (layer.type === "symbol") {
firstSymbolId = layer.id; firstSymbolId = layer.id
break; break
} }
} }
@ -142,58 +130,58 @@ export default function UnclusteredPointLayer({
"circle-stroke-width": 1, "circle-stroke-width": 1,
"circle-stroke-color": "#fff", "circle-stroke-color": "#fff",
}, },
layout: { // layout: {
visibility: focusedDistrictId ? "none" : "visible", // visibility: focusedDistrictId ? "visible" : "visible",
}, // },
}, },
firstSymbolId, firstSymbolId,
); )
map.on("mouseenter", "unclustered-point", () => { map.on("mouseenter", "unclustered-point", () => {
map.getCanvas().style.cursor = "pointer"; map.getCanvas().style.cursor = "pointer"
}); })
map.on("mouseleave", "unclustered-point", () => { map.on("mouseleave", "unclustered-point", () => {
map.getCanvas().style.cursor = ""; map.getCanvas().style.cursor = ""
}); })
} else { } else {
// Update visibility based on focused district // Update visibility based on focused district
map.setLayoutProperty("unclustered-point", "visibility", focusedDistrictId ? "none" : "visible"); map.setLayoutProperty("unclustered-point", "visibility", focusedDistrictId ? "none" : "visible")
} }
// Always ensure click handler is properly registered // Always ensure click handler is properly registered
map.off("click", "unclustered-point", handleIncidentClick); map.off("click", "unclustered-point", handleIncidentClick)
map.on("click", "unclustered-point", handleIncidentClick); map.on("click", "unclustered-point", handleIncidentClick)
} catch (error) { } catch (error) {
console.error("Error setting up unclustered point layer:", error); console.error("Error setting up unclustered point layer:", error)
}
} }
};
// 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()
} else { } else {
// Add event listener for style loading completion // Add event listener for style loading completion
const onStyleLoad = () => { const onStyleLoad = () => {
setupLayerAndSource(); setupLayerAndSource()
}; }
map.once("style.load", onStyleLoad); map.once("style.load", onStyleLoad)
// Also wait a bit and try again as a fallback // Also wait a bit and try again as a fallback
setTimeout(() => { setTimeout(() => {
if (map.isStyleLoaded()) { if (map.isStyleLoaded()) {
setupLayerAndSource(); setupLayerAndSource()
} }
}, 500); }, 500)
} }
return () => { return () => {
if (map) { if (map) {
map.off("click", "unclustered-point", handleIncidentClick); map.off("click", "unclustered-point", handleIncidentClick)
} }
}; }
}, [map, visible, focusedDistrictId, handleIncidentClick, crimes, filterCategory, geojsonData]); }, [map, visible, focusedDistrictId, handleIncidentClick, crimes, filterCategory])
return null; return null
} }

View File

@ -82,41 +82,25 @@ export default function MapView({
useEffect(() => { useEffect(() => {
if (!mapRef.current) return; if (!mapRef.current) return;
const mapElement = mapRef.current.getMap().getContainer(); const map = mapRef.current.getMap();
// Handle fly to event const handleFlyToEvent = (e: Event) => {
const handleMapFly = (e: CustomEvent) => { const customEvent = e as CustomEvent;
if (!e.detail || !mapRef.current) return; if (!customEvent.detail) return;
const { longitude, latitude, zoom, bearing, pitch, duration } = customEvent.detail;
const { center, zoom, bearing, pitch, duration, resetCamera } = e.detail; map.flyTo({
center: [longitude, latitude],
if (resetCamera) { zoom: zoom || 15,
// Reset to default view bearing: bearing || 0,
mapRef.current.flyTo({ pitch: pitch || 45,
center: [BASE_LONGITUDE, BASE_LATITUDE], duration: duration || 2000,
zoom: BASE_ZOOM,
bearing: BASE_BEARING,
pitch: BASE_PITCH,
duration,
essential: true
}); });
} else {
// Fly to specific location
mapRef.current.flyTo({
center,
zoom,
bearing,
pitch,
duration,
essential: true
});
}
}; };
mapElement.addEventListener('mapbox_fly', handleMapFly as EventListener); map.getContainer().addEventListener('mapbox_fly_to', handleFlyToEvent as EventListener);
return () => { return () => {
mapElement.removeEventListener('mapbox_fly', handleMapFly as EventListener); map.getContainer().removeEventListener('mapbox_fly_to', handleFlyToEvent as EventListener);
}; };
}, [mapRef.current]); }, [mapRef.current]);