"use client" import { useEffect, useState, useRef, useCallback } from "react" import { useMap } from "react-map-gl/mapbox" import { BASE_BEARING, BASE_PITCH, BASE_ZOOM, CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from "@/app/_utils/const/map" import { $Enums } from "@prisma/client" import DistrictPopup from "../pop-up/district-popup" import type { ICrimes } from "@/app/_utils/types/crimes" // Types for district properties export interface DistrictFeature { id: string name: string longitude: number latitude: number number_of_crime: number level: $Enums.crime_rates demographics: { number_of_unemployed: number population: number population_density: number year: number } geographics: { address: string land_area: number year: number latitude: number longitude: number } crime_incidents: Array<{ id: string timestamp: Date description: string status: string category: string type: string address: string latitude: number longitude: number }> selectedYear?: string selectedMonth?: string isFocused?: boolean // Add a property to track if district is focused } // District layer props export interface DistrictLayerProps { visible?: boolean onClick?: (feature: DistrictFeature) => void year: string month: string filterCategory: string | "all" crimes: ICrimes[] tilesetId?: string } export default function DistrictLayer({ visible = true, onClick, year, month, filterCategory = "all", crimes = [], tilesetId = MAPBOX_TILESET_ID, }: DistrictLayerProps) { const { current: map } = useMap() const [hoverInfo, setHoverInfo] = useState<{ x: number y: number feature: any } | null>(null) const selectedDistrictRef = useRef(null) const [selectedDistrict, setSelectedDistrict] = useState(null) const [focusedDistrictId, setFocusedDistrictId] = useState(null) const rotationAnimationRef = useRef(null) const bearingRef = useRef(0) const layersAdded = useRef(false) const crimeDataByDistrict = crimes.reduce( (acc, crime) => { const districtId = crime.district_id acc[districtId] = { number_of_crime: crime.number_of_crime, level: crime.level, } return acc }, {} as Record, ) const handleDistrictClick = (e: any) => { const incidentFeatures = map?.queryRenderedFeatures(e.point, { layers: ["unclustered-point", "clusters"] }) if (incidentFeatures && incidentFeatures.length > 0) { return } if (!map || !e.features || e.features.length === 0) return const feature = e.features[0] const districtId = feature.properties.kode_kec // If clicking the same district, deselect it if (focusedDistrictId === districtId) { setFocusedDistrictId(null) selectedDistrictRef.current = null setSelectedDistrict(null) // Reset animation and map view when deselecting if (rotationAnimationRef.current) { cancelAnimationFrame(rotationAnimationRef.current) rotationAnimationRef.current = null } bearingRef.current = 0 // Reset pitch and bearing with animation map.easeTo({ pitch: BASE_PITCH, bearing: 0, duration: 1500, easing: (t) => t * (2 - t), // easeOutQuad }) // Show all clusters again when unfocusing if (map.getMap().getLayer("clusters")) { map.getMap().setLayoutProperty("clusters", "visibility", "visible") } if (map.getMap().getLayer("unclustered-point")) { map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible") } return } const crimeData = crimeDataByDistrict[districtId] || {} let crime_incidents: Array<{ id: string timestamp: Date description: string status: string category: string type: string address: string latitude: number longitude: number }> = [] const districtCrimes = crimes.filter((crime) => crime.district_id === districtId) districtCrimes.forEach((crimeRecord) => { if (crimeRecord && crimeRecord.crime_incidents) { const incidents = crimeRecord.crime_incidents.map((incident) => ({ id: incident.id, timestamp: incident.timestamp, description: incident.description || "", status: incident.status || "", category: incident.crime_categories?.name || "", type: incident.crime_categories?.type || "", address: incident.locations?.address || "", latitude: incident.locations?.latitude || 0, longitude: incident.locations?.longitude || 0, })) crime_incidents = [...crime_incidents, ...incidents] } }) const firstDistrictCrime = districtCrimes.length > 0 ? districtCrimes[0] : null const selectedYearNum = year ? Number.parseInt(year) : new Date().getFullYear() let demographics = firstDistrictCrime?.districts.demographics?.find((d) => d.year === selectedYearNum) if (!demographics && firstDistrictCrime?.districts.demographics?.length) { demographics = firstDistrictCrime.districts.demographics.sort((a, b) => b.year - a.year)[0] console.log( `Tidak ada data demografis untuk tahun ${selectedYearNum}, menggunakan data tahun ${demographics.year}`, ) } let geographics = firstDistrictCrime?.districts.geographics?.find((g) => g.year === selectedYearNum) if (!geographics && firstDistrictCrime?.districts.geographics?.length) { const validGeographics = firstDistrictCrime.districts.geographics .filter((g) => g.year !== null) .sort((a, b) => (b.year || 0) - (a.year || 0)) geographics = validGeographics.length > 0 ? validGeographics[0] : firstDistrictCrime.districts.geographics[0] console.log( `Tidak ada data geografis untuk tahun ${selectedYearNum}, menggunakan data ${geographics.year ? `tahun ${geographics.year}` : "tanpa tahun"}`, ) } const clickLng = e.lngLat ? e.lngLat.lng : null const clickLat = e.lngLat ? e.lngLat.lat : null if (!geographics) { console.error("Missing geographics data for district:", districtId) return } if (!demographics) { console.error("Missing demographics data for district:", districtId) return } const district: DistrictFeature = { id: districtId, name: feature.properties.nama || feature.properties.kecamatan || "Unknown District", longitude: geographics.longitude || clickLng || 0, latitude: geographics.latitude || clickLat || 0, number_of_crime: crimeData.number_of_crime || 0, level: crimeData.level || $Enums.crime_rates.low, demographics: { number_of_unemployed: demographics.number_of_unemployed, population: demographics.population, population_density: demographics.population_density, year: demographics.year, }, geographics: { address: geographics.address || "", land_area: geographics.land_area || 0, year: geographics.year || 0, latitude: geographics.latitude, longitude: geographics.longitude, }, crime_incidents: crime_incidents || [], selectedYear: year, selectedMonth: month, isFocused: true, // Mark this district as focused } if (!district.longitude || !district.latitude) { console.error("Invalid district coordinates:", district) return } selectedDistrictRef.current = district setFocusedDistrictId(district.id) console.log("District clicked, selectedDistrictRef set to:", selectedDistrictRef.current) // Hide clusters when focusing on a district if (map.getMap().getLayer("clusters")) { map.getMap().setLayoutProperty("clusters", "visibility", "none") } if (map.getMap().getLayer("unclustered-point")) { map.getMap().setLayoutProperty("unclustered-point", "visibility", "none") } // Reset bearing before animation bearingRef.current = 0 // Animate to a pitched view focused on the district map.flyTo({ center: [district.longitude, district.latitude], zoom: 12.5, pitch: 75, bearing: 0, duration: 1500, easing: (t) => t * (2 - t), // easeOutQuad }) // Stop any existing rotation animation if (rotationAnimationRef.current) { cancelAnimationFrame(rotationAnimationRef.current) rotationAnimationRef.current = null } // Improved continuous bearing rotation function const startRotation = () => { if (!map || !focusedDistrictId) return const rotationSpeed = 0.05 // degrees per frame const animate = () => { if (!map || !focusedDistrictId) { if (rotationAnimationRef.current) { cancelAnimationFrame(rotationAnimationRef.current) rotationAnimationRef.current = null } return } // Update bearing with smooth increment bearingRef.current = (bearingRef.current + rotationSpeed) % 360 map.setBearing(bearingRef.current) // Continue the animation rotationAnimationRef.current = requestAnimationFrame(animate) } // Start the animation loop if (rotationAnimationRef.current) { cancelAnimationFrame(rotationAnimationRef.current) } rotationAnimationRef.current = requestAnimationFrame(animate) } // Start rotation after the initial flyTo completes setTimeout(startRotation, 1600) if (onClick) { onClick(district) } else { setSelectedDistrict(district) } } const handleIncidentClick = useCallback( (e: any) => { if (!map) return const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] }) if (!features || features.length === 0) return const incident = features[0] if (!incident.properties) return e.originalEvent.stopPropagation() e.preventDefault() const incidentDetails = { id: incident.properties.id, district: incident.properties.district, category: incident.properties.category, type: incident.properties.incidentType, description: incident.properties.description, status: incident.properties?.status || "Unknown", longitude: (incident.geometry as any).coordinates[0], latitude: (incident.geometry as any).coordinates[1], timestamp: new Date(), } console.log("Incident clicked:", incidentDetails) const customEvent = new CustomEvent("incident_click", { detail: incidentDetails, bubbles: true, }) if (map.getMap().getCanvas()) { map.getMap().getCanvas().dispatchEvent(customEvent) } else { document.dispatchEvent(customEvent) } }, [map], ) const handleClusterClick = useCallback( (e: any) => { if (!map) return e.originalEvent.stopPropagation() e.preventDefault() const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] }) 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, }) }) }, [map], ) const handleCloseDistrictPopup = useCallback(() => { console.log("Closing district popup") selectedDistrictRef.current = null setSelectedDistrict(null) setFocusedDistrictId(null) // Clear the focus when popup is closed // Cancel rotation animation when closing popup if (rotationAnimationRef.current) { cancelAnimationFrame(rotationAnimationRef.current) rotationAnimationRef.current = null } bearingRef.current = 0 // Reset pitch and bearing if (map) { map.easeTo({ zoom: BASE_ZOOM, pitch: BASE_PITCH, bearing: BASE_BEARING, duration: 1500, easing: (t) => t * (2 - t), // easeOutQuad }) // Show all clusters again when closing popup if (map.getMap().getLayer("clusters")) { map.getMap().setLayoutProperty("clusters", "visibility", "visible") } if (map.getMap().getLayer("unclustered-point")) { map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible") } } }, [map]) // Add handler for fly-to events 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 }); // Add a highlight or pulse effect to the target incident // This could be implemented by adding a temporary marker or animation // at the target coordinates if (map.getMap().getLayer('target-incident-highlight')) { map.getMap().removeLayer('target-incident-highlight'); } if (map.getMap().getSource('target-incident-highlight')) { map.getMap().removeSource('target-incident-highlight'); } map.getMap().addSource('target-incident-highlight', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Point', coordinates: [longitude, latitude] }, properties: {} } }); map.getMap().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.getMap().getLayer('target-incident-highlight')) return; size = (size % 20) + 1; map.getMap().setPaintProperty('target-incident-highlight', 'circle-radius', [ 'interpolate', ['linear'], ['zoom'], 10, size, 15, size * 1.5, 20, size * 2 ]); requestAnimationFrame(animatePulse); }; requestAnimationFrame(animatePulse); }; map.getMap().getCanvas().addEventListener('mapbox_fly_to', handleFlyToEvent as EventListener); return () => { if (map && map.getMap() && map.getMap().getCanvas()) { map.getMap().getCanvas().removeEventListener('mapbox_fly_to', handleFlyToEvent as EventListener); } }; }, [map]); useEffect(() => { if (!map || !visible) return const onStyleLoad = () => { if (!map) return try { if (!map.getMap().getSource("districts")) { const layers = map.getStyle().layers let firstSymbolId: string | undefined for (const layer of layers) { if (layer.type === "symbol") { firstSymbolId = layer.id break } } map.getMap().addSource("districts", { type: "vector", url: `mapbox://${tilesetId}`, }) const fillColorExpression: any = [ "case", ["has", "kode_kec"], [ "match", ["get", "kode_kec"], ...(focusedDistrictId ? [ [ focusedDistrictId, crimeDataByDistrict[focusedDistrictId]?.level === "low" ? CRIME_RATE_COLORS.low : crimeDataByDistrict[focusedDistrictId]?.level === "medium" ? CRIME_RATE_COLORS.medium : crimeDataByDistrict[focusedDistrictId]?.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, ], "rgba(0,0,0,0.05)", ] : Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { return [ districtId, data.level === "low" ? CRIME_RATE_COLORS.low : data.level === "medium" ? CRIME_RATE_COLORS.medium : data.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, ] })), focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default, ], CRIME_RATE_COLORS.default, ] if (!map.getMap().getLayer("district-fill")) { map.getMap().addLayer( { id: "district-fill", type: "fill", source: "districts", "source-layer": "Districts", paint: { "fill-color": fillColorExpression, "fill-opacity": 0.6, }, }, firstSymbolId, ) } if (!map.getMap().getLayer("district-line")) { map.getMap().addLayer( { id: "district-line", type: "line", source: "districts", "source-layer": "Districts", paint: { "line-color": "#ffffff", "line-width": 1, "line-opacity": 0.5, }, }, firstSymbolId, ) } if (!map.getMap().getLayer("district-extrusion")) { map.getMap().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 || "", crimeDataByDistrict[focusedDistrictId || ""]?.level === "low" ? CRIME_RATE_COLORS.low : crimeDataByDistrict[focusedDistrictId || ""]?.level === "medium" ? CRIME_RATE_COLORS.medium : crimeDataByDistrict[focusedDistrictId || ""]?.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, "transparent", ], "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 || ""], }, firstSymbolId, ) } if (crimes.length > 0 && !map.getMap().getSource("crime-incidents")) { const allIncidents = crimes.flatMap((crime) => { let filteredIncidents = crime.crime_incidents if (filterCategory !== "all") { filteredIncidents = crime.crime_incidents.filter( (incident) => incident.crime_categories.name === filterCategory, ) } return filteredIncidents.map((incident) => ({ type: "Feature" as const, properties: { id: incident.id, district: crime.districts.name, category: incident.crime_categories.name, incidentType: incident.crime_categories.type, level: crime.level, description: incident.description, }, geometry: { type: "Point" as const, coordinates: [incident.locations.longitude, incident.locations.latitude], }, })) }) map.getMap().addSource("crime-incidents", { type: "geojson", data: { type: "FeatureCollection", features: allIncidents, }, cluster: true, clusterMaxZoom: 14, clusterRadius: 50, }) if (!map.getMap().getLayer("clusters")) { map.getMap().addLayer( { id: "clusters", type: "circle", source: "crime-incidents", filter: ["has", "point_count"], paint: { "circle-color": ["step", ["get", "point_count"], "#51bbd6", 5, "#f1f075", 15, "#f28cb1"], "circle-radius": ["step", ["get", "point_count"], 20, 5, 30, 15, 40], "circle-opacity": 0.75, }, layout: { visibility: "visible", }, }, firstSymbolId, ) } if (!map.getMap().getLayer("cluster-count")) { map.getMap().addLayer({ id: "cluster-count", type: "symbol", source: "crime-incidents", filter: ["has", "point_count"], layout: { "text-field": "{point_count_abbreviated}", "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"], "text-size": 12, }, paint: { "text-color": "#ffffff", }, }) } if (!map.getMap().getLayer("unclustered-point")) { map.getMap().addLayer( { id: "unclustered-point", type: "circle", source: "crime-incidents", filter: ["!", ["has", "point_count"]], paint: { "circle-color": "#11b4da", "circle-radius": 8, "circle-stroke-width": 1, "circle-stroke-color": "#fff", }, layout: { visibility: "visible", }, }, firstSymbolId, ) } map.on("mouseenter", "clusters", () => { map.getCanvas().style.cursor = "pointer" }) map.on("mouseleave", "clusters", () => { map.getCanvas().style.cursor = "" }) map.on("mouseenter", "unclustered-point", () => { map.getCanvas().style.cursor = "pointer" }) map.on("mouseleave", "unclustered-point", () => { map.getCanvas().style.cursor = "" }) map.on("mouseenter", "district-fill", () => { map.getCanvas().style.cursor = "pointer" }) map.on("mouseleave", "district-fill", () => { map.getCanvas().style.cursor = "" }) map.off("click", "clusters", handleClusterClick) map.off("click", "unclustered-point", handleIncidentClick) map.on("click", "clusters", handleClusterClick) map.on("click", "unclustered-point", handleIncidentClick) map.off("click", "district-fill", handleDistrictClick) map.on("click", "district-fill", handleDistrictClick) map.on("mouseleave", "district-fill", () => setHoverInfo(null)) layersAdded.current = true } } else { if (map.getMap().getLayer("district-fill")) { map.getMap().setPaintProperty("district-fill", "fill-color", [ "case", ["has", "kode_kec"], [ "match", ["get", "kode_kec"], ...(focusedDistrictId ? [ [ focusedDistrictId, crimeDataByDistrict[focusedDistrictId]?.level === "low" ? CRIME_RATE_COLORS.low : crimeDataByDistrict[focusedDistrictId]?.level === "medium" ? CRIME_RATE_COLORS.medium : crimeDataByDistrict[focusedDistrictId]?.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, ], "rgba(0,0,0,0.05)", ] : Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { return [ districtId, data.level === "low" ? CRIME_RATE_COLORS.low : data.level === "medium" ? CRIME_RATE_COLORS.medium : data.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, ] })), focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default, ], CRIME_RATE_COLORS.default, ] as any) } // Re-add click handler to ensure it works after timelapse map.off("click", "district-fill", handleDistrictClick) map.on("click", "district-fill", handleDistrictClick) } } catch (error) { console.error("Error adding district layers:", error) } } if (map.isStyleLoaded()) { onStyleLoad() } else { map.once("style.load", onStyleLoad) } return () => { if (map) { map.off("click", "district-fill", handleDistrictClick) } } }, [map, visible, tilesetId, crimes, filterCategory, year, month, handleIncidentClick, handleClusterClick]) // Add an additional effect to ensure click handlers are properly maintained useEffect(() => { if (!map || !layersAdded.current) return // Re-attach the click handler after any data changes if (map.getMap().getLayer("district-fill")) { map.off("click", "district-fill", handleDistrictClick) map.on("click", "district-fill", handleDistrictClick) } // Ensure district-extrusion settings are maintained after timelapse if (map.getMap().getLayer("district-extrusion")) { map.getMap().setFilter("district-extrusion", ["==", ["get", "kode_kec"], focusedDistrictId || ""]); // Re-apply the extrusion color based on the focused district map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-color", [ "case", ["has", "kode_kec"], [ "match", ["get", "kode_kec"], focusedDistrictId || "", crimeDataByDistrict[focusedDistrictId || ""]?.level === "low" ? CRIME_RATE_COLORS.low : crimeDataByDistrict[focusedDistrictId || ""]?.level === "medium" ? CRIME_RATE_COLORS.medium : crimeDataByDistrict[focusedDistrictId || ""]?.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, "transparent", ], "transparent", ]); // Ensure extrusion height is restored if needed if (focusedDistrictId) { map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-height", [ "case", ["has", "kode_kec"], ["match", ["get", "kode_kec"], focusedDistrictId, 800, 0], 0, ]); } } return () => { if (map) { map.off("click", "district-fill", handleDistrictClick) } } }, [map, year, month, crimeDataByDistrict, focusedDistrictId]) useEffect(() => { if (!map || !layersAdded.current) return try { if (map.getMap().getLayer("district-fill")) { const colorEntries = focusedDistrictId ? [ [ focusedDistrictId, crimeDataByDistrict[focusedDistrictId]?.level === "low" ? CRIME_RATE_COLORS.low : crimeDataByDistrict[focusedDistrictId]?.level === "medium" ? CRIME_RATE_COLORS.medium : crimeDataByDistrict[focusedDistrictId]?.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, ], "rgba(0,0,0,0.05)", ] : Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { if (!data || !data.level) { return [districtId, CRIME_RATE_COLORS.default] } return [ districtId, data.level === "low" ? CRIME_RATE_COLORS.low : data.level === "medium" ? CRIME_RATE_COLORS.medium : data.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, ] }) const fillColorExpression = [ "case", ["has", "kode_kec"], [ "match", ["get", "kode_kec"], ...colorEntries, focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default, ], CRIME_RATE_COLORS.default, ] as any map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression) } if (map.getMap().getLayer("district-extrusion")) { map.getMap().setFilter("district-extrusion", ["==", ["get", "kode_kec"], focusedDistrictId || ""]) map .getMap() .setPaintProperty("district-extrusion", "fill-extrusion-color", [ "case", ["has", "kode_kec"], [ "match", ["get", "kode_kec"], focusedDistrictId || "", crimeDataByDistrict[focusedDistrictId || ""]?.level === "low" ? CRIME_RATE_COLORS.low : crimeDataByDistrict[focusedDistrictId || ""]?.level === "medium" ? CRIME_RATE_COLORS.medium : crimeDataByDistrict[focusedDistrictId || ""]?.level === "high" ? CRIME_RATE_COLORS.high : CRIME_RATE_COLORS.default, "transparent", ], "transparent", ]) 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 .getMap() .setPaintProperty("district-extrusion", "fill-extrusion-height", [ "case", ["has", "kode_kec"], ["match", ["get", "kode_kec"], focusedDistrictId, currentHeight, 0], 0, ]) if (progress < 1) { requestAnimationFrame(animateHeight) } } requestAnimationFrame(animateHeight) } 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 .getMap() .setPaintProperty("district-extrusion", "fill-extrusion-height", [ "case", ["has", "kode_kec"], ["match", ["get", "kode_kec"], "", currentHeight, 0], 0, ]) if (progress < 1) { requestAnimationFrame(animateHeightDown) } } requestAnimationFrame(animateHeightDown) } } } catch (error) { console.error("Error updating district layer:", error) } }, [map, crimes, crimeDataByDistrict, focusedDistrictId]) useEffect(() => { if (!map || !map.getMap().getSource("crime-incidents")) return try { const allIncidents = crimes.flatMap((crime) => { if (!crime.crime_incidents) return [] let filteredIncidents = crime.crime_incidents if (filterCategory !== "all") { filteredIncidents = crime.crime_incidents.filter( (incident) => incident.crime_categories && incident.crime_categories.name === filterCategory, ) } return filteredIncidents .map((incident) => { if (!incident.locations) { console.warn("Missing location for incident:", incident.id) return null } return { type: "Feature" as const, properties: { id: incident.id, district: crime.districts?.name || "Unknown", category: incident.crime_categories?.name || "Unknown", incidentType: incident.crime_categories?.type || "Unknown", level: crime.level || "low", description: incident.description || "", }, geometry: { type: "Point" as const, coordinates: [incident.locations.longitude || 0, incident.locations.latitude || 0], }, } }) .filter(Boolean) }) ; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({ type: "FeatureCollection", features: allIncidents as GeoJSON.Feature[], }) } catch (error) { console.error("Error updating incident data:", error) } }, [map, crimes, filterCategory]) useEffect(() => { if (selectedDistrictRef.current) { const districtId = selectedDistrictRef.current.id const crimeData = crimeDataByDistrict[districtId] || {} const districtCrime = crimes.find((crime) => crime.district_id === districtId) if (districtCrime) { const selectedYearNum = year ? Number.parseInt(year) : new Date().getFullYear() let demographics = districtCrime.districts.demographics?.find((d) => d.year === selectedYearNum) if (!demographics && districtCrime.districts.demographics?.length) { demographics = districtCrime.districts.demographics.sort((a, b) => b.year - a.year)[0] } let geographics = districtCrime.districts.geographics?.find((g) => g.year === selectedYearNum) if (!geographics && districtCrime.districts.geographics?.length) { const validGeographics = districtCrime.districts.geographics .filter((g) => g.year !== null) .sort((a, b) => (b.year || 0) - (a.year || 0)) geographics = validGeographics.length > 0 ? validGeographics[0] : districtCrime.districts.geographics[0] } if (!demographics || !geographics) { console.error("Missing district data:", { demographics, geographics }) return } const crime_incidents = districtCrime.crime_incidents .filter((incident) => filterCategory === "all" || incident.crime_categories.name === filterCategory) .map((incident) => ({ id: incident.id, timestamp: incident.timestamp, description: incident.description, status: incident.status || "", category: incident.crime_categories.name, type: incident.crime_categories.type || "", address: incident.locations.address || "", latitude: incident.locations.latitude, longitude: incident.locations.longitude, })) const updatedDistrict: DistrictFeature = { ...selectedDistrictRef.current, number_of_crime: crimeData.number_of_crime || 0, level: crimeData.level || selectedDistrictRef.current.level, demographics: { number_of_unemployed: demographics.number_of_unemployed, population: demographics.population, population_density: demographics.population_density, year: demographics.year, }, geographics: { address: geographics.address || "", land_area: geographics.land_area || 0, year: geographics.year || 0, latitude: geographics.latitude, longitude: geographics.longitude, }, crime_incidents, selectedYear: year, selectedMonth: month, } selectedDistrictRef.current = updatedDistrict setSelectedDistrict((prevDistrict) => { if ( prevDistrict?.id === updatedDistrict.id && prevDistrict?.selectedYear === updatedDistrict.selectedYear && prevDistrict?.selectedMonth === updatedDistrict.selectedMonth ) { return prevDistrict } return updatedDistrict }) } } }, [crimes, filterCategory, year, month]) useEffect(() => { return () => { if (rotationAnimationRef.current) { cancelAnimationFrame(rotationAnimationRef.current) rotationAnimationRef.current = null } } }, []) if (!visible) return null return ( <> {selectedDistrict && ( )} ) }