diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index c0558d7..3e8bdc2 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -362,20 +362,20 @@ export default function CrimeMap() { {/* District Layer with crime data - don't pass onClick if we want internal popup */} - - - {/* */} + + {/* Popup for selected incident */} {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( <> diff --git a/sigap-website/app/_components/map/fly-to.tsx b/sigap-website/app/_components/map/fly-to.tsx index dbe872e..711c939 100644 --- a/sigap-website/app/_components/map/fly-to.tsx +++ b/sigap-website/app/_components/map/fly-to.tsx @@ -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) { + // Track active animations + const animationRef = useRef(null) + useEffect(() => { if (!map) return @@ -21,71 +24,96 @@ export default function FlyToHandler({ map }: Pick) { 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]) diff --git a/sigap-website/app/_components/map/layers/cluster-layer.tsx b/sigap-website/app/_components/map/layers/cluster-layer.tsx index 42e3739..a268d75 100644 --- a/sigap-website/app/_components/map/layers/cluster-layer.tsx +++ b/sigap-website/app/_components/map/layers/cluster-layer.tsx @@ -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) diff --git a/sigap-website/app/_components/map/layers/district-extrusion-layer.tsx b/sigap-website/app/_components/map/layers/district-extrusion-layer.tsx index c290eb3..7f74271 100644 --- a/sigap-website/app/_components/map/layers/district-extrusion-layer.tsx +++ b/sigap-website/app/_components/map/layers/district-extrusion-layer.tsx @@ -14,6 +14,7 @@ export default function DistrictExtrusionLayer({ const animationRef = useRef(null) const bearingRef = useRef(0) const rotationAnimationRef = useRef(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 diff --git a/sigap-website/app/_components/map/layers/layers.tsx b/sigap-website/app/_components/map/layers/layers.tsx index 623d990..2511aa2 100644 --- a/sigap-website/app/_components/map/layers/layers.tsx +++ b/sigap-website/app/_components/map/layers/layers.tsx @@ -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 diff --git a/sigap-website/app/_components/map/layers/uncluster-layer.tsx b/sigap-website/app/_components/map/layers/uncluster-layer.tsx index a263a8d..929b3e3 100644 --- a/sigap-website/app/_components/map/layers/uncluster-layer.tsx +++ b/sigap-website/app/_components/map/layers/uncluster-layer.tsx @@ -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)