refactor: refactor district layer

This commit is contained in:
vergiLgood1 2025-05-05 05:15:05 +07:00
parent 03a5e527d4
commit 078bf969bc
6 changed files with 297 additions and 170 deletions

View File

@ -362,20 +362,20 @@ export default function CrimeMap() {
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md"> <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 */} {/* District Layer with crime data - don't pass onClick if we want internal popup */}
<DistrictLayer {/* <DistrictLayer
crimes={filteredCrimes || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
/>
{/* <Layers
crimes={filteredCrimes || []} crimes={filteredCrimes || []}
year={selectedYear.toString()} year={selectedYear.toString()}
month={selectedMonth.toString()} month={selectedMonth.toString()}
filterCategory={selectedCategory} filterCategory={selectedCategory}
/> */} /> */}
<Layers
crimes={filteredCrimes || []}
year={selectedYear.toString()}
month={selectedMonth.toString()}
filterCategory={selectedCategory}
/>
{/* Popup for selected incident */} {/* Popup for selected incident */}
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<> <>

View File

@ -1,10 +1,13 @@
"use client" "use client"
import { IBaseLayerProps } from "@/app/_utils/types/map" import { IBaseLayerProps } from "@/app/_utils/types/map"
import { useEffect } from "react" import { useEffect, useRef } from "react"
export default function FlyToHandler({ map }: Pick<IBaseLayerProps, "map">) { export default function FlyToHandler({ map }: Pick<IBaseLayerProps, "map">) {
// Track active animations
const animationRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
if (!map) return if (!map) return
@ -21,71 +24,96 @@ export default function FlyToHandler({ map }: Pick<IBaseLayerProps, "map">) {
duration: duration || 2000, duration: duration || 2000,
}) })
// Cancel any existing animation
if (animationRef.current) {
cancelAnimationFrame(animationRef.current)
}
// Add a highlight or pulse effect to the target incident // Add a highlight or pulse effect to the target incident
if (map.getLayer("target-incident-highlight")) { try {
map.removeLayer("target-incident-highlight") if (map.getLayer("target-incident-highlight")) {
} map.removeLayer("target-incident-highlight")
}
if (map.getSource("target-incident-highlight")) { if (map.getSource("target-incident-highlight")) {
map.removeSource("target-incident-highlight") map.removeSource("target-incident-highlight")
} }
map.addSource("target-incident-highlight", { map.addSource("target-incident-highlight", {
type: "geojson", type: "geojson",
data: { data: {
type: "Feature", type: "Feature",
geometry: { geometry: {
type: "Point", type: "Point",
coordinates: [longitude, latitude], coordinates: [longitude, latitude],
},
properties: {},
}, },
properties: {}, })
},
})
map.addLayer({ map.addLayer({
id: "target-incident-highlight", id: "target-incident-highlight",
source: "target-incident-highlight", source: "target-incident-highlight",
type: "circle", type: "circle",
paint: { paint: {
"circle-radius": ["interpolate", ["linear"], ["zoom"], 10, 10, 15, 15, 20, 20], "circle-radius": ["interpolate", ["linear"], ["zoom"], 10, 10, 15, 15, 20, 20],
"circle-color": "#ff0000", "circle-color": "#ff0000",
"circle-opacity": 0.7, "circle-opacity": 0.7,
"circle-stroke-width": 2, "circle-stroke-width": 2,
"circle-stroke-color": "#ffffff", "circle-stroke-color": "#ffffff",
}, },
}) })
// Add a pulsing effect using animations // Add a pulsing effect using animations
let size = 10 let size = 10
const animatePulse = () => { const animatePulse = () => {
if (!map || !map.getLayer("target-incident-highlight")) return 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", [ map.setPaintProperty("target-incident-highlight", "circle-radius", [
"interpolate", "interpolate",
["linear"], ["linear"],
["zoom"], ["zoom"],
10, 10,
size, size,
15, 15,
size * 1.5, size * 1.5,
20, 20,
size * 2, 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) 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 () => { return () => {
if (map && map.getCanvas()) { if (map && map.getCanvas()) {
map.getCanvas().removeEventListener("mapbox_fly_to", handleFlyToEvent as EventListener) 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]) }, [map])

View File

@ -26,14 +26,43 @@ export default function ClusterLayer({
if (!features || features.length === 0) return if (!features || features.length === 0) return
const clusterId: number = features[0].properties?.cluster_id as number 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({ try {
center: (features[0].geometry as any).coordinates, // Get the expanded zoom level for this cluster
zoom: zoom ?? undefined, (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], [map],
) )
@ -114,6 +143,7 @@ export default function ClusterLayer({
map.getCanvas().style.cursor = "" map.getCanvas().style.cursor = ""
}) })
// Remove and re-add click handler to avoid duplicates
map.off("click", "clusters", handleClusterClick) map.off("click", "clusters", handleClusterClick)
map.on("click", "clusters", handleClusterClick) map.on("click", "clusters", handleClusterClick)
} else { } else {
@ -124,6 +154,10 @@ export default function ClusterLayer({
if (map.getLayer("cluster-count")) { if (map.getLayer("cluster-count")) {
map.setLayoutProperty("cluster-count", "visibility", focusedDistrictId ? "none" : "visible") 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) { } catch (error) {
console.error("Error adding cluster layer:", error) console.error("Error adding cluster layer:", error)

View File

@ -14,6 +14,7 @@ export default function DistrictExtrusionLayer({
const animationRef = useRef<number | null>(null) const animationRef = useRef<number | null>(null)
const bearingRef = useRef(0) const bearingRef = useRef(0)
const rotationAnimationRef = useRef<number | null>(null) const rotationAnimationRef = useRef<number | null>(null)
const extrusionCreatedRef = useRef(false)
// Handle extrusion layer creation and updates // Handle extrusion layer creation and updates
useEffect(() => { useEffect(() => {
@ -32,55 +33,64 @@ export default function DistrictExtrusionLayer({
} }
} }
if (!map.getLayer("district-extrusion")) { // Remove existing layer if it exists to avoid conflicts
map.addLayer( if (map.getLayer("district-extrusion")) {
{ map.removeLayer("district-extrusion")
id: "district-extrusion", extrusionCreatedRef.current = false
type: "fill-extrusion", }
source: "districts",
"source-layer": "Districts", // Make sure the districts source exists
paint: { if (!map.getSource("districts")) {
"fill-extrusion-color": [ if (!tilesetId) {
"case", console.error("No tileset ID provided for districts source");
["has", "kode_kec"], return;
[ }
"match",
["get", "kode_kec"], map.addSource("districts", {
focusedDistrictId || "", type: "vector",
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level), url: `mapbox://${tilesetId}`,
"transparent", })
], }
// 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", "transparent",
], ],
"fill-extrusion-height": [ "transparent",
"case", ],
["has", "kode_kec"], "fill-extrusion-height": [
["match", ["get", "kode_kec"], focusedDistrictId || "", 500, 0], "case",
0, ["has", "kode_kec"],
], ["match", ["get", "kode_kec"], focusedDistrictId || "", 0, 0], // Start at 0 for animation
"fill-extrusion-base": 0, 0,
"fill-extrusion-opacity": 0.8, ],
}, "fill-extrusion-base": 0,
filter: ["==", ["get", "kode_kec"], focusedDistrictId || ""], "fill-extrusion-opacity": 0.8,
}, },
firstSymbolId, filter: ["==", ["get", "kode_kec"], focusedDistrictId || ""],
) },
} else { firstSymbolId
// Update existing layer )
map.setFilter("district-extrusion", ["==", ["get", "kode_kec"], focusedDistrictId || ""])
map.setPaintProperty("district-extrusion", "fill-extrusion-color", [ extrusionCreatedRef.current = true
"case",
["has", "kode_kec"], // If a district is focused, start the animation
[ if (focusedDistrictId) {
"match", animateExtrusion()
["get", "kode_kec"],
focusedDistrictId || "",
getCrimeRateColor(crimeDataByDistrict[focusedDistrictId || ""]?.level),
"transparent",
],
"transparent",
])
} }
} catch (error) { } catch (error) {
console.error("Error adding district extrusion layer:", error) console.error("Error adding district extrusion layer:", error)
@ -99,73 +109,42 @@ export default function DistrictExtrusionLayer({
animationRef.current = null animationRef.current = null
} }
} }
}, [map, visible, tilesetId, focusedDistrictId, crimeDataByDistrict]) }, [map, visible, tilesetId])
// Handle extrusion height animation // Update filter and color when focused district changes
useEffect(() => { useEffect(() => {
if (!map || !map.getLayer("district-extrusion")) return if (!map || !map.getLayer("district-extrusion")) return
try { 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) { if (focusedDistrictId) {
const startHeight = 0 animateExtrusion()
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)
} else { } 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 // Stop rotation when unfocusing
if (rotationAnimationRef.current) { if (rotationAnimationRef.current) {
cancelAnimationFrame(rotationAnimationRef.current) cancelAnimationFrame(rotationAnimationRef.current)
@ -174,9 +153,9 @@ export default function DistrictExtrusionLayer({
bearingRef.current = 0 bearingRef.current = 0
} }
} catch (error) { } catch (error) {
console.error("Error animating district extrusion:", error) console.error("Error updating district extrusion:", error)
} }
}, [map, focusedDistrictId]) }, [map, focusedDistrictId, crimeDataByDistrict])
// Cleanup on unmount // Cleanup on unmount
useEffect(() => { 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 // Start rotation animation
const startRotation = () => { const startRotation = () => {
if (!map || !focusedDistrictId) return if (!map || !focusedDistrictId) return

View File

@ -51,6 +51,31 @@ export default function Layers({
const crimeDataByDistrict = processCrimeDataByDistrict(crimes) 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 // Handle district selection
const handleDistrictClick = (district: IDistrictFeature) => { const handleDistrictClick = (district: IDistrictFeature) => {
selectedDistrictRef.current = district selectedDistrictRef.current = district

View File

@ -33,20 +33,40 @@ export default function UnclusteredPointLayer({
status: incident.properties?.status || "Unknown", status: incident.properties?.status || "Unknown",
longitude: (incident.geometry as any).coordinates[0], longitude: (incident.geometry as any).coordinates[0],
latitude: (incident.geometry as any).coordinates[1], latitude: (incident.geometry as any).coordinates[1],
timestamp: new Date(), timestamp: new Date(incident.properties.timestamp || Date.now()),
} }
console.log("Incident clicked:", incidentDetails) console.log("Incident clicked:", incidentDetails)
// Create a custom event with incident details
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
if (map.getCanvas()) { if (map.getCanvas()) {
map.getCanvas().dispatchEvent(customEvent) 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 { } else {
document.dispatchEvent(customEvent) document.dispatchEvent(flyToEvent)
} }
}, },
[map], [map],
@ -101,6 +121,10 @@ export default function UnclusteredPointLayer({
} else if (map.getLayer("unclustered-point")) { } else if (map.getLayer("unclustered-point")) {
// 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")
// Ensure click handler is registered
map.off("click", "unclustered-point", handleIncidentClick)
map.on("click", "unclustered-point", handleIncidentClick)
} }
} catch (error) { } catch (error) {
console.error("Error adding unclustered point layer:", error) console.error("Error adding unclustered point layer:", error)