Add DistrictLayer and MapLayerManager components for enhanced map functionality

- Implemented DistrictLayer to display district information and crime data on the map.
- Added interactivity for district selection, including animations and data retrieval.
- Created MapLayerManager to manage multiple layers on the map, including district and crime layers.
- Integrated crime data processing and visualization based on user interactions.
- Ensured proper handling of map style loading and layer management.
This commit is contained in:
vergiLgood1 2025-05-04 02:45:10 +07:00
parent 4623fac52b
commit 7d01547a02
8 changed files with 2666 additions and 1065 deletions

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react" import { useState, useEffect, useRef } from "react"
import { Pause, Play } from "lucide-react" import { Pause, Play } from "lucide-react"
import { Button } from "@/app/_components/ui/button" import { Button } from "@/app/_components/ui/button"
import { cn } from "@/app/_lib/utils" import { cn } from "@/app/_lib/utils"
@ -13,7 +13,6 @@ interface CrimeTimelapseProps {
className?: string className?: string
autoPlay?: boolean autoPlay?: boolean
autoPlaySpeed?: number // Time to progress through one month in ms autoPlaySpeed?: number // Time to progress through one month in ms
enablePerformanceMode?: boolean // Flag untuk mode performa
} }
export function CrimeTimelapse({ export function CrimeTimelapse({
@ -24,7 +23,6 @@ export function CrimeTimelapse({
className, className,
autoPlay = true, autoPlay = true,
autoPlaySpeed = 1000, // Speed of month progress autoPlaySpeed = 1000, // Speed of month progress
enablePerformanceMode = true, // Default aktifkan mode performa tinggi
}: CrimeTimelapseProps) { }: CrimeTimelapseProps) {
const [currentYear, setCurrentYear] = useState<number>(startYear) const [currentYear, setCurrentYear] = useState<number>(startYear)
const [currentMonth, setCurrentMonth] = useState<number>(1) // Start at January (1) const [currentMonth, setCurrentMonth] = useState<number>(1) // Start at January (1)
@ -33,23 +31,25 @@ export function CrimeTimelapse({
const [isDragging, setIsDragging] = useState<boolean>(false) const [isDragging, setIsDragging] = useState<boolean>(false)
const animationRef = useRef<number | null>(null) const animationRef = useRef<number | null>(null)
const lastUpdateTimeRef = useRef<number>(0) const lastUpdateTimeRef = useRef<number>(0)
const frameSkipCountRef = useRef<number>(0) // Untuk pelompatan frame
// Jumlah frame yang akan dilewati saat performance mode aktif // Notify parent about playing state changes
const frameSkipThreshold = enablePerformanceMode ? 3 : 0 useEffect(() => {
if (onPlayingChange) {
onPlayingChange(isPlaying || isDragging)
}
}, [isPlaying, isDragging, onPlayingChange])
// Hitung total bulan dari awal hingga akhir tahun (memoisasi) // Calculate total months from start to end year
const totalMonths = useRef(((endYear - startYear) * 12) + 12) // +12 untuk memasukkan semua bulan tahun akhir const totalMonths = ((endYear - startYear) * 12) + 12 // +12 to include all months of end year
// Menggunakan useCallback untuk fungsi yang sering dipanggil const calculateOverallProgress = (): number => {
const calculateOverallProgress = useCallback((): number => {
const yearDiff = currentYear - startYear const yearDiff = currentYear - startYear
const monthProgress = (yearDiff * 12) + (currentMonth - 1) const monthProgress = (yearDiff * 12) + (currentMonth - 1)
return ((monthProgress + progress) / (totalMonths.current - 1)) * 100 return ((monthProgress + progress) / (totalMonths - 1)) * 100
}, [currentYear, currentMonth, progress, startYear, totalMonths]) }
const calculateTimeFromProgress = useCallback((overallProgress: number): { year: number; month: number; progress: number } => { const calculateTimeFromProgress = (overallProgress: number): { year: number; month: number; progress: number } => {
const totalProgress = (overallProgress * (totalMonths.current - 1)) / 100 const totalProgress = (overallProgress * (totalMonths - 1)) / 100
const monthsFromStart = Math.floor(totalProgress) const monthsFromStart = Math.floor(totalProgress)
const year = startYear + Math.floor(monthsFromStart / 12) const year = startYear + Math.floor(monthsFromStart / 12)
@ -61,72 +61,61 @@ export function CrimeTimelapse({
month: Math.min(month, 12), month: Math.min(month, 12),
progress: monthProgress progress: monthProgress
} }
}, [startYear, endYear, totalMonths]) }
// Calculate the current position for the active marker // Calculate the current position for the active marker
const calculateMarkerPosition = useCallback((): string => { const calculateMarkerPosition = (): string => {
return `${calculateOverallProgress()}%` const overallProgress = calculateOverallProgress()
}, [calculateOverallProgress]) return `${overallProgress}%`
}
// Optimasi animasi dengan throttling berbasis requestAnimationFrame const animate = (timestamp: number) => {
const animate = useCallback((timestamp: number) => {
if (!lastUpdateTimeRef.current) { if (!lastUpdateTimeRef.current) {
lastUpdateTimeRef.current = timestamp lastUpdateTimeRef.current = timestamp
} }
if (!isDragging) { if (!isDragging) {
// Performance optimization: skip frames in performance mode const elapsed = timestamp - lastUpdateTimeRef.current
frameSkipCountRef.current++ const progressIncrement = elapsed / autoPlaySpeed
if (frameSkipCountRef.current > frameSkipThreshold || !enablePerformanceMode) { let newProgress = progress + progressIncrement
frameSkipCountRef.current = 0 let newMonth = currentMonth
let newYear = currentYear
const elapsed = timestamp - lastUpdateTimeRef.current if (newProgress >= 1) {
const progressIncrement = elapsed / autoPlaySpeed newProgress = 0
newMonth = currentMonth + 1
let newProgress = progress + progressIncrement if (newMonth > 12) {
let newMonth = currentMonth newMonth = 1
let newYear = currentYear newYear = currentYear + 1
if (newProgress >= 1) { if (newYear > endYear) {
newProgress = 0 newYear = startYear
newMonth = currentMonth + 1
if (newMonth > 12) {
newMonth = 1 newMonth = 1
newYear = currentYear + 1
if (newYear > endYear) {
newYear = startYear
newMonth = 1
}
} }
setCurrentMonth(newMonth)
setCurrentYear(newYear)
} }
setProgress(newProgress) setCurrentMonth(newMonth)
setCurrentYear(newYear)
// Notify parent component only after calculating all changes
if (onChange) {
onChange(newYear, newMonth, newProgress)
}
lastUpdateTimeRef.current = timestamp
} }
setProgress(newProgress)
if (onChange) {
onChange(newYear, newMonth, newProgress)
}
lastUpdateTimeRef.current = timestamp
} }
if (isPlaying) { if (isPlaying) {
animationRef.current = requestAnimationFrame(animate) animationRef.current = requestAnimationFrame(animate)
} }
}, [isPlaying, isDragging, progress, currentMonth, currentYear, onChange, }
autoPlaySpeed, startYear, endYear, enablePerformanceMode, frameSkipThreshold])
useEffect(() => { useEffect(() => {
if (isPlaying) { if (isPlaying) {
lastUpdateTimeRef.current = 0 lastUpdateTimeRef.current = 0
frameSkipCountRef.current = 0
animationRef.current = requestAnimationFrame(animate) animationRef.current = requestAnimationFrame(animate)
} else if (animationRef.current) { } else if (animationRef.current) {
cancelAnimationFrame(animationRef.current) cancelAnimationFrame(animationRef.current)
@ -137,21 +126,17 @@ export function CrimeTimelapse({
cancelAnimationFrame(animationRef.current) cancelAnimationFrame(animationRef.current)
} }
} }
}, [isPlaying, animate]) }, [isPlaying, currentYear, currentMonth, progress, isDragging])
// Memoized handler for play/pause const handlePlayPause = () => {
const handlePlayPause = useCallback(() => { const newPlayingState = !isPlaying
setIsPlaying(prevState => { setIsPlaying(newPlayingState)
const newPlayingState = !prevState if (onPlayingChange) {
if (onPlayingChange) { onPlayingChange(newPlayingState)
onPlayingChange(newPlayingState) }
} }
return newPlayingState
})
}, [onPlayingChange])
// Memoized handler for slider change const handleSliderChange = (value: number[]) => {
const handleSliderChange = useCallback((value: number[]) => {
const overallProgress = value[0] const overallProgress = value[0]
const { year, month, progress } = calculateTimeFromProgress(overallProgress) const { year, month, progress } = calculateTimeFromProgress(overallProgress)
@ -162,47 +147,42 @@ export function CrimeTimelapse({
if (onChange) { if (onChange) {
onChange(year, month, progress) onChange(year, month, progress)
} }
}, [calculateTimeFromProgress, onChange]) }
const handleSliderDragStart = useCallback(() => { const handleSliderDragStart = () => {
setIsDragging(true) setIsDragging(true)
if (onPlayingChange) { if (onPlayingChange) {
onPlayingChange(true) // Treat dragging as a form of "playing" for performance optimization onPlayingChange(true) // Treat dragging as a form of "playing" for performance optimization
} }
}, [onPlayingChange]) }
const handleSliderDragEnd = useCallback(() => { const handleSliderDragEnd = () => {
setIsDragging(false) setIsDragging(false)
if (onPlayingChange) { if (onPlayingChange) {
onPlayingChange(isPlaying) // Restore to actual playing state onPlayingChange(isPlaying) // Restore to actual playing state
} }
}, [isPlaying, onPlayingChange]) }
// Komputasi tahun marker dilakukan sekali saja dan di-cache // Create year markers
const yearMarkers = useRef<number[]>([]) const yearMarkers = []
useEffect(() => { for (let year = startYear; year <= endYear; year++) {
const markers = [] yearMarkers.push(year)
for (let year = startYear; year <= endYear; year++) { }
markers.push(year)
}
yearMarkers.current = markers
}, [startYear, endYear])
// Gunakan React.memo untuk komponen child jika diperlukan
// Contoh: const YearMarker = React.memo(({ year, isActive }) => { ... })
return ( return (
<div className={cn("w-full bg-transparent text-emerald-500", className)}> <div className={cn("w-full bg-transparent text-emerald-500", className)}>
<div className="relative"> <div className="relative">
{/* Current month/year marker that moves with the slider */}
{isPlaying && (
<div <div
className={cn( className={cn(
"absolute bottom-full mb-2 transform -translate-x-1/2 px-3 py-1 rounded-full text-xs font-bold z-20 transition-colors duration-300 bg-emerald-500 text-background", "absolute bottom-full mb-2 transform -translate-x-1/2 px-3 py-1 rounded-full text-xs font-bold z-20 transition-colors duration-300 bg-emerald-500 text-background",
)} )}
style={{ left: calculateMarkerPosition() }} style={{ left: calculateMarkerPosition() }}
> >
{isPlaying}{getMonthName(currentMonth)} {currentYear} {getMonthName(currentMonth)} {currentYear}
</div> </div>
)}
{/* Wrap button and slider in their container */} {/* Wrap button and slider in their container */}
<div className="px-2 flex gap-x-2"> <div className="px-2 flex gap-x-2">
@ -234,12 +214,12 @@ export function CrimeTimelapse({
{/* Year markers */} {/* Year markers */}
<div className="flex items-center relative h-10"> <div className="flex items-center relative h-10">
<div className="absolute inset-0 h-full flex"> <div className="absolute inset-0 h-full flex">
{yearMarkers.current.map((year, index) => ( {yearMarkers.map((year, index) => (
<div <div
key={year} key={year}
className={cn( className={cn(
"flex-1 h-full flex items-center justify-center relative", "flex-1 h-full flex items-center justify-center relative",
index < yearMarkers.current.length - 1 && "" index < yearMarkers.length - 1 && ""
)} )}
> >
<div <div
@ -257,4 +237,4 @@ export function CrimeTimelapse({
</div> </div>
</div> </div>
) )
} }

View File

@ -2,7 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
import { Skeleton } from "@/app/_components/ui/skeleton" import { Skeleton } from "@/app/_components/ui/skeleton"
import DistrictLayer, { type DistrictFeature } from "./layers/district-layer" import DistrictLayer, { type DistrictFeature } from "./layers/district-layer-old"
import MapView from "./map" import MapView from "./map"
import { Button } from "@/app/_components/ui/button" import { Button } from "@/app/_components/ui/button"
import { AlertCircle } from "lucide-react" import { AlertCircle } from "lucide-react"
@ -260,19 +260,8 @@ export default function CrimeMap() {
year={selectedYear.toString()} year={selectedYear.toString()}
month={selectedMonth.toString()} month={selectedMonth.toString()}
filterCategory={selectedCategory} filterCategory={selectedCategory}
isTimelapsePlaying={isTimelapsePlaying}
/>
{/* Pass onClick if you want to handle districts externally */}
{/*
<DistrictLayer
onClick={handleDistrictClick}
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

@ -0,0 +1,31 @@
"use client"
import { useEffect, useRef } from "react"
import { useMap } from "react-map-gl/mapbox"
export interface BaseLayerProps {
visible?: boolean
beforeId?: string
}
export default function BaseLayer({ visible = true, beforeId }: BaseLayerProps) {
const { current: map } = useMap()
const layersAdded = useRef(false)
// Find the first symbol layer in the map style to insert layers before it
const getBeforeLayerId = (): string | undefined => {
if (!map || !beforeId) return undefined
if (beforeId) return beforeId
const layers = map.getStyle().layers
for (const layer of layers) {
if (layer.type === "symbol") {
return layer.id
}
}
return undefined
}
return { map, layersAdded, getBeforeLayerId }
}

View File

@ -0,0 +1,337 @@
"use client"
import { useEffect, useRef, useCallback } from "react"
import { useMap } from "react-map-gl/mapbox"
import type { ICrimes } from "@/app/_utils/types/crimes"
export interface CrimeClusterLayerProps {
visible?: boolean
crimes: ICrimes[]
filterCategory: string | "all"
isTimelapsePlaying?: boolean
beforeId?: string
}
export default function CrimeClusterLayer({
visible = true,
crimes = [],
filterCategory = "all",
isTimelapsePlaying = false,
beforeId,
}: CrimeClusterLayerProps) {
const { current: map } = useMap()
const layersAdded = useRef(false)
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 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],
)
// Initialize crime clusters and points
useEffect(() => {
if (!map || !visible || crimes.length === 0) return
const onStyleLoad = () => {
if (!map) return
try {
// Get the first symbol layer
let firstSymbolId = beforeId
if (!firstSymbolId) {
const layers = map.getStyle().layers
for (const layer of layers) {
if (layer.type === "symbol") {
firstSymbolId = layer.id
break
}
}
}
if (!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: isTimelapsePlaying ? "none" : "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,
"visibility": isTimelapsePlaying ? "none" : "visible",
},
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: isTimelapsePlaying ? "none" : "visible",
},
},
firstSymbolId,
)
}
// Add event handlers
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 = ""
})
// Attach click handlers
map.off("click", "clusters", handleClusterClick)
map.off("click", "unclustered-point", handleIncidentClick)
map.on("click", "clusters", handleClusterClick)
map.on("click", "unclustered-point", handleIncidentClick)
layersAdded.current = true
}
} catch (error) {
console.error("Error adding crime cluster layers:", error)
}
}
if (map.isStyleLoaded()) {
onStyleLoad()
} else {
map.once("style.load", onStyleLoad)
}
return () => {
if (map) {
map.off("click", "clusters", handleClusterClick)
map.off("click", "unclustered-point", handleIncidentClick)
}
}
}, [map, visible, crimes, filterCategory, handleClusterClick, handleIncidentClick, beforeId, isTimelapsePlaying])
// Update crime data when filters change
useEffect(() => {
if (!map || !map.getMap().getSource("crime-incidents")) return
try {
// If timeline is playing, hide all point/cluster layers to improve performance
if (isTimelapsePlaying) {
// Hide all incident points during timelapse
if (map.getMap().getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "none")
}
if (map.getMap().getLayer("unclustered-point")) {
map.getMap().setLayoutProperty("unclustered-point", "visibility", "none")
}
if (map.getMap().getLayer("cluster-count")) {
map.getMap().setLayoutProperty("cluster-count", "visibility", "none")
}
// Update the source with empty data to free up resources
; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
type: "FeatureCollection",
features: [],
})
} else {
// When not playing, show all layers again
if (map.getMap().getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
}
if (map.getMap().getLayer("unclustered-point")) {
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
}
if (map.getMap().getLayer("cluster-count")) {
map.getMap().setLayoutProperty("cluster-count", "visibility", "visible")
}
// Restore detailed incidents when timelapse stops
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)
})
// Update the source with detailed data
; (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, isTimelapsePlaying])
return null
}

View File

@ -0,0 +1,194 @@
"use client"
import { useEffect, useRef } from "react"
import { useMap } from "react-map-gl/mapbox"
import { CRIME_RATE_COLORS } from "@/app/_utils/const/map"
import { $Enums } from "@prisma/client"
export interface DistrictExtrusionLayerProps {
visible?: boolean
focusedDistrictId: string | null
crimeDataByDistrict: Record<string, { number_of_crime?: number; level?: $Enums.crime_rates }>
tilesetId: string
beforeId?: string
}
export default function DistrictExtrusionLayer({
visible = true,
focusedDistrictId,
crimeDataByDistrict,
tilesetId,
beforeId,
}: DistrictExtrusionLayerProps) {
const { current: map } = useMap()
const layersAdded = useRef(false)
useEffect(() => {
if (!map || !visible) return
const onStyleLoad = () => {
if (!map) return
try {
// Make sure the districts source exists
if (!map.getMap().getSource("districts")) {
map.getMap().addSource("districts", {
type: "vector",
url: `mapbox://${tilesetId}`,
})
}
// Get the first symbol layer
let firstSymbolId = beforeId
if (!firstSymbolId) {
const layers = map.getStyle().layers
for (const layer of layers) {
if (layer.type === "symbol") {
firstSymbolId = layer.id
break
}
}
}
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,
)
layersAdded.current = true
}
} catch (error) {
console.error("Error adding extrusion layer:", error)
}
}
if (map.isStyleLoaded()) {
onStyleLoad()
} else {
map.once("style.load", onStyleLoad)
}
}, [map, visible, tilesetId, beforeId])
useEffect(() => {
if (!map || !layersAdded.current) return
try {
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 extrusion layer:", error)
}
}, [map, focusedDistrictId, crimeDataByDistrict])
return null
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,226 @@
// "use client"
// import { useState, useEffect, useRef } from "react"
// import { useMap } from "react-map-gl/mapbox"
// import { MAPBOX_TILESET_ID } from "@/app/_utils/const/map"
// import { $Enums } from "@prisma/client"
// import type { ICrimes } from "@/app/_utils/types/crimes"
// import DistrictLayer, { DistrictFeature } from "./district-layer"
// import DistrictExtrusionLayer from "./district-extrusion-layer"
// import CrimeClusterLayer from "./crime-cluster-layer"
// export interface MapLayerManagerProps {
// visible?: boolean
// crimes: ICrimes[]
// year: string
// month: string
// filterCategory: string | "all"
// tilesetId?: string
// isTimelapsePlaying?: boolean
// onDistrictClick?: (feature: DistrictFeature) => void
// }
// export default function MapLayerManager({
// visible = true,
// crimes = [],
// year,
// month,
// filterCategory = "all",
// tilesetId = MAPBOX_TILESET_ID,
// isTimelapsePlaying = false,
// onDistrictClick,
// }: MapLayerManagerProps) {
// const { current: map } = useMap()
// const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null)
// const [isStyleLoaded, setIsStyleLoaded] = useState<boolean>(false)
// const [beforeId, setBeforeId] = useState<string | undefined>(undefined)
// const initAttempts = useRef(0)
// // Compute crime data by district for all layer components to use
// 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<string, { number_of_crime?: number; level?: $Enums.crime_rates }>,
// )
// // Ensure map is ready after mounting - try multiple approaches
// useEffect(() => {
// if (!map) {
// console.error("Map not available in MapLayerManager");
// return;
// }
// console.log("MapLayerManager mounted, checking map status");
// // Direct initialization if already loaded
// if (map.getMap().isStyleLoaded()) {
// console.log("Map style already loaded - direct init");
// setIsStyleLoaded(true);
// try {
// const layers = map.getMap().getStyle().layers;
// for (const layer of layers) {
// if (layer.type === "symbol") {
// setBeforeId(layer.id);
// break;
// }
// }
// } catch (err) {
// console.warn("Error finding symbol layer:", err);
// }
// return;
// }
// // Listen for style load event
// const onStyleLoad = () => {
// console.log("Map style.load event fired");
// setIsStyleLoaded(true);
// try {
// const layers = map.getMap().getStyle().layers;
// for (const layer of layers) {
// if (layer.type === "symbol") {
// setBeforeId(layer.id);
// break;
// }
// }
// } catch (err) {
// console.warn("Error finding symbol layer:", err);
// }
// };
// // Add event listener
// map.getMap().once('style.load', onStyleLoad);
// // Multiple retry attempts with increasing delays
// const checkStyleLoaded = () => {
// initAttempts.current += 1;
// if (initAttempts.current > 10) {
// console.error("Failed to detect loaded map style after 10 attempts");
// return;
// }
// if (map.getMap().isStyleLoaded()) {
// console.log(`Map style loaded (detected on attempt ${initAttempts.current})`);
// map.getMap().off('style.load', onStyleLoad);
// setIsStyleLoaded(true);
// try {
// const layers = map.getMap().getStyle().layers;
// for (const layer of layers) {
// if (layer.type === "symbol") {
// setBeforeId(layer.id);
// break;
// }
// }
// } catch (err) {
// console.warn("Error finding symbol layer:", err);
// }
// } else {
// console.log(`Waiting for map style to load... (attempt ${initAttempts.current})`);
// setTimeout(checkStyleLoaded, 200 * initAttempts.current); // Increasing delay
// }
// };
// // Start checking after a short delay
// setTimeout(checkStyleLoaded, 100);
// // Cleanup
// return () => {
// map.getMap().off('style.load', onStyleLoad);
// };
// }, [map]);
// // Force a re-check when map or visibility changes
// useEffect(() => {
// if (!map || !visible) return;
// if (!isStyleLoaded && map.getMap().isStyleLoaded()) {
// console.log("Map style detected as loaded after prop change");
// setIsStyleLoaded(true);
// try {
// const layers = map.getMap().getStyle().layers;
// for (const layer of layers) {
// if (layer.type === "symbol") {
// setBeforeId(layer.id);
// break;
// }
// }
// } catch (err) {
// console.warn("Error finding symbol layer:", err);
// }
// }
// }, [map, visible, isStyleLoaded]);
// // Print debug info
// useEffect(() => {
// console.log("MapLayerManager state:", {
// mapAvailable: !!map,
// isStyleLoaded,
// beforeId,
// crimeCount: crimes.length,
// visible
// });
// }, [map, isStyleLoaded, beforeId, crimes, visible]);
// // Debug: Force isStyleLoaded after a timeout as a last resort
// useEffect(() => {
// if (isStyleLoaded || !map) return;
// const forceTimeout = setTimeout(() => {
// if (!isStyleLoaded && map) {
// console.warn("Forcing isStyleLoaded=true after timeout");
// setIsStyleLoaded(true);
// }
// }, 2000);
// return () => clearTimeout(forceTimeout);
// }, [map, isStyleLoaded]);
// if (!visible || !map) {
// console.log("MapLayerManager not rendering: visible=", visible, "map=", !!map);
// return null;
// }
// return (
// <>
// {map && (isStyleLoaded || initAttempts.current > 5) && (
// <>
// <DistrictLayer
// visible={true}
// onClick={onDistrictClick}
// year={year}
// month={month}
// filterCategory={filterCategory}
// crimes={crimes}
// tilesetId={tilesetId}
// isTimelapsePlaying={isTimelapsePlaying}
// onDistrictFocus={setFocusedDistrictId}
// />
// <DistrictExtrusionLayer
// visible={true}
// focusedDistrictId={focusedDistrictId}
// crimeDataByDistrict={crimeDataByDistrict}
// tilesetId={tilesetId!}
// beforeId={beforeId}
// />
// <CrimeClusterLayer
// visible={!focusedDistrictId}
// crimes={crimes}
// filterCategory={filterCategory}
// isTimelapsePlaying={isTimelapsePlaying}
// beforeId={beforeId}
// />
// </>
// )
// }
// </>
// );
// }