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">
{/* 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 && (
<>

View File

@ -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])

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)