refactor: refactor district layer
This commit is contained in:
parent
03a5e527d4
commit
078bf969bc
|
@ -362,20 +362,20 @@ export default function CrimeMap() {
|
|||
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
|
||||
{/* District Layer with crime data - don't pass onClick if we want internal popup */}
|
||||
|
||||
<DistrictLayer
|
||||
crimes={filteredCrimes || []}
|
||||
year={selectedYear.toString()}
|
||||
month={selectedMonth.toString()}
|
||||
filterCategory={selectedCategory}
|
||||
/>
|
||||
|
||||
{/* <Layers
|
||||
{/* <DistrictLayer
|
||||
crimes={filteredCrimes || []}
|
||||
year={selectedYear.toString()}
|
||||
month={selectedMonth.toString()}
|
||||
filterCategory={selectedCategory}
|
||||
/> */}
|
||||
|
||||
<Layers
|
||||
crimes={filteredCrimes || []}
|
||||
year={selectedYear.toString()}
|
||||
month={selectedMonth.toString()}
|
||||
filterCategory={selectedCategory}
|
||||
/>
|
||||
|
||||
{/* Popup for selected incident */}
|
||||
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
|
||||
<>
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
"use client"
|
||||
|
||||
import { IBaseLayerProps } from "@/app/_utils/types/map"
|
||||
import { useEffect } from "react"
|
||||
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
|
||||
|
||||
|
@ -21,71 +24,96 @@ export default function FlyToHandler({ map }: Pick<IBaseLayerProps, "map">) {
|
|||
duration: duration || 2000,
|
||||
})
|
||||
|
||||
// Cancel any existing animation
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
|
||||
// Add a highlight or pulse effect to the target incident
|
||||
if (map.getLayer("target-incident-highlight")) {
|
||||
map.removeLayer("target-incident-highlight")
|
||||
}
|
||||
try {
|
||||
if (map.getLayer("target-incident-highlight")) {
|
||||
map.removeLayer("target-incident-highlight")
|
||||
}
|
||||
|
||||
if (map.getSource("target-incident-highlight")) {
|
||||
map.removeSource("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],
|
||||
map.addSource("target-incident-highlight", {
|
||||
type: "geojson",
|
||||
data: {
|
||||
type: "Feature",
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [longitude, latitude],
|
||||
},
|
||||
properties: {},
|
||||
},
|
||||
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",
|
||||
},
|
||||
})
|
||||
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 pulsing effect using animations
|
||||
let size = 10
|
||||
const animatePulse = () => {
|
||||
if (!map || !map.getLayer("target-incident-highlight")) return
|
||||
// Add a pulsing effect using animations
|
||||
let size = 10
|
||||
const animatePulse = () => {
|
||||
if (!map || !map.getLayer("target-incident-highlight")) {
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
animationRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
size = (size % 20) + 1
|
||||
size = (size % 20) + 1
|
||||
|
||||
map.setPaintProperty("target-incident-highlight", "circle-radius", [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
10,
|
||||
size,
|
||||
15,
|
||||
size * 1.5,
|
||||
20,
|
||||
size * 2,
|
||||
])
|
||||
map.setPaintProperty("target-incident-highlight", "circle-radius", [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
10,
|
||||
size,
|
||||
15,
|
||||
size * 1.5,
|
||||
20,
|
||||
size * 2,
|
||||
])
|
||||
|
||||
requestAnimationFrame(animatePulse)
|
||||
animationRef.current = requestAnimationFrame(animatePulse)
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(animatePulse)
|
||||
} catch (error) {
|
||||
console.error("Error adding highlight effect:", error)
|
||||
}
|
||||
|
||||
requestAnimationFrame(animatePulse)
|
||||
}
|
||||
|
||||
// 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])
|
||||
|
||||
|
|
|
@ -26,14 +26,43 @@ export default function ClusterLayer({
|
|||
if (!features || features.length === 0) return
|
||||
|
||||
const clusterId: number = features[0].properties?.cluster_id as number
|
||||
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(clusterId, (err, zoom) => {
|
||||
if (err) return
|
||||
|
||||
map.easeTo({
|
||||
center: (features[0].geometry as any).coordinates,
|
||||
zoom: zoom ?? undefined,
|
||||
try {
|
||||
// Get the expanded zoom level for this cluster
|
||||
(map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(clusterId, (err, zoom) => {
|
||||
if (err) {
|
||||
console.error("Error getting cluster expansion zoom:", err)
|
||||
return
|
||||
}
|
||||
|
||||
const coordinates = (features[0].geometry as any).coordinates
|
||||
|
||||
// Dispatch a custom event for the fly-to behavior
|
||||
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({
|
||||
center: coordinates,
|
||||
zoom: zoom ?? 12,
|
||||
duration: 1000,
|
||||
easing: (t) => t * (2 - t) // easeOutQuad
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Error handling cluster click:", error)
|
||||
}
|
||||
},
|
||||
[map],
|
||||
)
|
||||
|
@ -114,6 +143,7 @@ export default function ClusterLayer({
|
|||
map.getCanvas().style.cursor = ""
|
||||
})
|
||||
|
||||
// Remove and re-add click handler to avoid duplicates
|
||||
map.off("click", "clusters", handleClusterClick)
|
||||
map.on("click", "clusters", handleClusterClick)
|
||||
} else {
|
||||
|
@ -124,6 +154,10 @@ export default function ClusterLayer({
|
|||
if (map.getLayer("cluster-count")) {
|
||||
map.setLayoutProperty("cluster-count", "visibility", focusedDistrictId ? "none" : "visible")
|
||||
}
|
||||
|
||||
// Update the cluster click handler
|
||||
map.off("click", "clusters", handleClusterClick)
|
||||
map.on("click", "clusters", handleClusterClick)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error adding cluster layer:", error)
|
||||
|
|
|
@ -14,6 +14,7 @@ export default function DistrictExtrusionLayer({
|
|||
const animationRef = useRef<number | null>(null)
|
||||
const bearingRef = useRef(0)
|
||||
const rotationAnimationRef = useRef<number | null>(null)
|
||||
const extrusionCreatedRef = useRef(false)
|
||||
|
||||
// Handle extrusion layer creation and updates
|
||||
useEffect(() => {
|
||||
|
@ -32,55 +33,64 @@ export default function DistrictExtrusionLayer({
|
|||
}
|
||||
}
|
||||
|
||||
if (!map.getLayer("district-extrusion")) {
|
||||
map.addLayer(
|
||||
{
|
||||
id: "district-extrusion",
|
||||
type: "fill-extrusion",
|
||||
source: "districts",
|
||||
"source-layer": "Districts",
|
||||
paint: {
|
||||
"fill-extrusion-color": [
|
||||
"case",
|
||||
["has", "kode_kec"],
|
||||
[
|
||||
"match",
|
||||
["get", "kode_kec"],
|
||||
focusedDistrictId || "",
|
||||
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level),
|
||||
"transparent",
|
||||
],
|
||||
// Remove existing layer if it exists to avoid conflicts
|
||||
if (map.getLayer("district-extrusion")) {
|
||||
map.removeLayer("district-extrusion")
|
||||
extrusionCreatedRef.current = false
|
||||
}
|
||||
|
||||
// Make sure the districts source exists
|
||||
if (!map.getSource("districts")) {
|
||||
if (!tilesetId) {
|
||||
console.error("No tileset ID provided for districts source");
|
||||
return;
|
||||
}
|
||||
|
||||
map.addSource("districts", {
|
||||
type: "vector",
|
||||
url: `mapbox://${tilesetId}`,
|
||||
})
|
||||
}
|
||||
|
||||
// Create the extrusion layer
|
||||
map.addLayer(
|
||||
{
|
||||
id: "district-extrusion",
|
||||
type: "fill-extrusion",
|
||||
source: "districts",
|
||||
"source-layer": "Districts",
|
||||
paint: {
|
||||
"fill-extrusion-color": [
|
||||
"case",
|
||||
["has", "kode_kec"],
|
||||
[
|
||||
"match",
|
||||
["get", "kode_kec"],
|
||||
focusedDistrictId || "",
|
||||
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level),
|
||||
"transparent",
|
||||
],
|
||||
"fill-extrusion-height": [
|
||||
"case",
|
||||
["has", "kode_kec"],
|
||||
["match", ["get", "kode_kec"], focusedDistrictId || "", 500, 0],
|
||||
0,
|
||||
],
|
||||
"fill-extrusion-base": 0,
|
||||
"fill-extrusion-opacity": 0.8,
|
||||
},
|
||||
filter: ["==", ["get", "kode_kec"], focusedDistrictId || ""],
|
||||
"transparent",
|
||||
],
|
||||
"fill-extrusion-height": [
|
||||
"case",
|
||||
["has", "kode_kec"],
|
||||
["match", ["get", "kode_kec"], focusedDistrictId || "", 0, 0], // Start at 0 for animation
|
||||
0,
|
||||
],
|
||||
"fill-extrusion-base": 0,
|
||||
"fill-extrusion-opacity": 0.8,
|
||||
},
|
||||
firstSymbolId,
|
||||
)
|
||||
} else {
|
||||
// Update existing layer
|
||||
map.setFilter("district-extrusion", ["==", ["get", "kode_kec"], focusedDistrictId || ""])
|
||||
filter: ["==", ["get", "kode_kec"], focusedDistrictId || ""],
|
||||
},
|
||||
firstSymbolId
|
||||
)
|
||||
|
||||
map.setPaintProperty("district-extrusion", "fill-extrusion-color", [
|
||||
"case",
|
||||
["has", "kode_kec"],
|
||||
[
|
||||
"match",
|
||||
["get", "kode_kec"],
|
||||
focusedDistrictId || "",
|
||||
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level),
|
||||
"transparent",
|
||||
],
|
||||
"transparent",
|
||||
])
|
||||
extrusionCreatedRef.current = true
|
||||
|
||||
// If a district is focused, start the animation
|
||||
if (focusedDistrictId) {
|
||||
animateExtrusion()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error adding district extrusion layer:", error)
|
||||
|
@ -99,73 +109,42 @@ export default function DistrictExtrusionLayer({
|
|||
animationRef.current = null
|
||||
}
|
||||
}
|
||||
}, [map, visible, tilesetId, focusedDistrictId, crimeDataByDistrict])
|
||||
}, [map, visible, tilesetId])
|
||||
|
||||
// Handle extrusion height animation
|
||||
// Update filter and color when focused district changes
|
||||
useEffect(() => {
|
||||
if (!map || !map.getLayer("district-extrusion")) return
|
||||
|
||||
try {
|
||||
// Update the filter for the extrusion layer
|
||||
map.setFilter("district-extrusion", ["==", ["get", "kode_kec"], focusedDistrictId || ""])
|
||||
|
||||
// Update the extrusion color
|
||||
map.setPaintProperty("district-extrusion", "fill-extrusion-color", [
|
||||
"case",
|
||||
["has", "kode_kec"],
|
||||
[
|
||||
"match",
|
||||
["get", "kode_kec"],
|
||||
focusedDistrictId || "",
|
||||
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level),
|
||||
"transparent",
|
||||
],
|
||||
"transparent",
|
||||
])
|
||||
|
||||
// Reset height for animation
|
||||
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
|
||||
"case",
|
||||
["has", "kode_kec"],
|
||||
["match", ["get", "kode_kec"], focusedDistrictId || "", 0, 0],
|
||||
0,
|
||||
])
|
||||
|
||||
// Start animation if district is focused, otherwise reset
|
||||
if (focusedDistrictId) {
|
||||
const startHeight = 0
|
||||
const targetHeight = 800
|
||||
const duration = 700
|
||||
const startTime = performance.now()
|
||||
|
||||
const animateHeight = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const easedProgress = progress * (2 - progress)
|
||||
const currentHeight = startHeight + (targetHeight - startHeight) * easedProgress
|
||||
|
||||
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
|
||||
"case",
|
||||
["has", "kode_kec"],
|
||||
["match", ["get", "kode_kec"], focusedDistrictId, currentHeight, 0],
|
||||
0,
|
||||
])
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animateHeight)
|
||||
} else {
|
||||
// Start rotation after extrusion completes
|
||||
startRotation()
|
||||
}
|
||||
}
|
||||
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
animationRef.current = requestAnimationFrame(animateHeight)
|
||||
animateExtrusion()
|
||||
} else {
|
||||
const startHeight = 800
|
||||
const targetHeight = 0
|
||||
const duration = 500
|
||||
const startTime = performance.now()
|
||||
|
||||
const animateHeightDown = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const easedProgress = progress * (2 - progress)
|
||||
const currentHeight = startHeight + (targetHeight - startHeight) * easedProgress
|
||||
|
||||
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
|
||||
"case",
|
||||
["has", "kode_kec"],
|
||||
["match", ["get", "kode_kec"], "", currentHeight, 0],
|
||||
0,
|
||||
])
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animateHeightDown)
|
||||
}
|
||||
}
|
||||
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
animationRef.current = requestAnimationFrame(animateHeightDown)
|
||||
|
||||
// Stop rotation when unfocusing
|
||||
if (rotationAnimationRef.current) {
|
||||
cancelAnimationFrame(rotationAnimationRef.current)
|
||||
|
@ -174,9 +153,9 @@ export default function DistrictExtrusionLayer({
|
|||
bearingRef.current = 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error animating district extrusion:", error)
|
||||
console.error("Error updating district extrusion:", error)
|
||||
}
|
||||
}, [map, focusedDistrictId])
|
||||
}, [map, focusedDistrictId, crimeDataByDistrict])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
@ -192,6 +171,43 @@ export default function DistrictExtrusionLayer({
|
|||
}
|
||||
}, [])
|
||||
|
||||
// Animate extrusion height
|
||||
const animateExtrusion = () => {
|
||||
if (!map || !map.getLayer("district-extrusion") || !focusedDistrictId) return
|
||||
|
||||
if (animationRef.current) {
|
||||
cancelAnimationFrame(animationRef.current)
|
||||
}
|
||||
|
||||
const startHeight = 0
|
||||
const targetHeight = 800
|
||||
const duration = 700
|
||||
const startTime = performance.now()
|
||||
|
||||
const animate = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
const easedProgress = progress * (2 - progress) // easeOutQuad
|
||||
const currentHeight = startHeight + (targetHeight - startHeight) * easedProgress
|
||||
|
||||
map.setPaintProperty("district-extrusion", "fill-extrusion-height", [
|
||||
"case",
|
||||
["has", "kode_kec"],
|
||||
["match", ["get", "kode_kec"], focusedDistrictId, currentHeight, 0],
|
||||
0,
|
||||
])
|
||||
|
||||
if (progress < 1) {
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
} else {
|
||||
// Start rotation after extrusion completes
|
||||
startRotation()
|
||||
}
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
// Start rotation animation
|
||||
const startRotation = () => {
|
||||
if (!map || !focusedDistrictId) return
|
||||
|
|
|
@ -51,6 +51,31 @@ export default function Layers({
|
|||
|
||||
const crimeDataByDistrict = processCrimeDataByDistrict(crimes)
|
||||
|
||||
// Set up custom event handler for cluster clicks to ensure it works across components
|
||||
useEffect(() => {
|
||||
if (!mapboxMap) return;
|
||||
|
||||
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 district selection
|
||||
const handleDistrictClick = (district: IDistrictFeature) => {
|
||||
selectedDistrictRef.current = district
|
||||
|
|
|
@ -33,20 +33,40 @@ export default function UnclusteredPointLayer({
|
|||
status: incident.properties?.status || "Unknown",
|
||||
longitude: (incident.geometry as any).coordinates[0],
|
||||
latitude: (incident.geometry as any).coordinates[1],
|
||||
timestamp: new Date(),
|
||||
timestamp: new Date(incident.properties.timestamp || Date.now()),
|
||||
}
|
||||
|
||||
console.log("Incident clicked:", incidentDetails)
|
||||
|
||||
// Create a custom event with incident details
|
||||
const customEvent = new CustomEvent("incident_click", {
|
||||
detail: incidentDetails,
|
||||
bubbles: true,
|
||||
})
|
||||
|
||||
// Dispatch the event on both the map canvas and document to ensure it's captured
|
||||
if (map.getCanvas()) {
|
||||
map.getCanvas().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(customEvent)
|
||||
document.dispatchEvent(flyToEvent)
|
||||
}
|
||||
},
|
||||
[map],
|
||||
|
@ -101,6 +121,10 @@ export default function UnclusteredPointLayer({
|
|||
} else if (map.getLayer("unclustered-point")) {
|
||||
// Update visibility based on focused district
|
||||
map.setLayoutProperty("unclustered-point", "visibility", focusedDistrictId ? "none" : "visible")
|
||||
|
||||
// Ensure click handler is registered
|
||||
map.off("click", "unclustered-point", handleIncidentClick)
|
||||
map.on("click", "unclustered-point", handleIncidentClick)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error adding unclustered point layer:", error)
|
||||
|
|
Loading…
Reference in New Issue