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