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

View File

@ -2,7 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
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 { Button } from "@/app/_components/ui/button"
import { AlertCircle } from "lucide-react"
@ -260,19 +260,8 @@ export default function CrimeMap() {
year={selectedYear.toString()}
month={selectedMonth.toString()}
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 */}
{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

View File

@ -54,7 +54,6 @@ export interface DistrictLayerProps {
filterCategory: string | "all"
crimes: ICrimes[]
tilesetId?: string
isTimelapsePlaying?: boolean // Add new prop to track timeline playing state
}
export default function DistrictLayer({
@ -65,7 +64,6 @@ export default function DistrictLayer({
filterCategory = "all",
crimes = [],
tilesetId = MAPBOX_TILESET_ID,
isTimelapsePlaying = false, // Default to false
}: DistrictLayerProps) {
const { current: map } = useMap()
@ -78,11 +76,9 @@ export default function DistrictLayer({
const selectedDistrictRef = useRef<DistrictFeature | null>(null)
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null)
const persistentFocusedDistrictRef = useRef<string | null>(null)
const rotationAnimationRef = useRef<number | null>(null)
const bearingRef = useRef(0)
const layersAdded = useRef(false)
const isFocusedMode = useRef(false)
const crimeDataByDistrict = crimes.reduce(
(acc, crime) => {
@ -112,8 +108,6 @@ export default function DistrictLayer({
// If clicking the same district, deselect it
if (focusedDistrictId === districtId) {
setFocusedDistrictId(null)
persistentFocusedDistrictRef.current = null
isFocusedMode.current = false
selectedDistrictRef.current = null
setSelectedDistrict(null)
@ -251,8 +245,6 @@ export default function DistrictLayer({
selectedDistrictRef.current = district
setFocusedDistrictId(district.id)
persistentFocusedDistrictRef.current = district.id
isFocusedMode.current = true
console.log("District clicked, selectedDistrictRef set to:", selectedDistrictRef.current)
// Hide clusters when focusing on a district
@ -389,9 +381,7 @@ export default function DistrictLayer({
console.log("Closing district popup")
selectedDistrictRef.current = null
setSelectedDistrict(null)
setFocusedDistrictId(null)
persistentFocusedDistrictRef.current = null
isFocusedMode.current = false
setFocusedDistrictId(null) // Clear the focus when popup is closed
// Cancel rotation animation when closing popup
if (rotationAnimationRef.current) {
@ -885,37 +875,6 @@ export default function DistrictLayer({
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 []
@ -952,76 +911,14 @@ export default function DistrictLayer({
})
.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])
// Add a new effect to update district colors even during timelapse
useEffect(() => {
if (!map || !map.getMap().getSource("districts")) 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
// Make district fills more prominent during timelapse
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression)
map.getMap().setPaintProperty("district-fill", "fill-opacity",
isTimelapsePlaying ? 0.85 : 0.6) // Increase opacity during timelapse
}
} catch (error) {
console.error("Error updating district fill:", error)
}
}, [map, crimeDataByDistrict, focusedDistrictId, isTimelapsePlaying])
}, [map, crimes, filterCategory])
useEffect(() => {
if (selectedDistrictRef.current) {
@ -1088,7 +985,6 @@ export default function DistrictLayer({
crime_incidents,
selectedYear: year,
selectedMonth: month,
isFocused: true, // Ensure this stays true
}
selectedDistrictRef.current = updatedDistrict
@ -1103,92 +999,9 @@ export default function DistrictLayer({
}
return updatedDistrict
})
if (districtId === persistentFocusedDistrictRef.current && !focusedDistrictId) {
setFocusedDistrictId(districtId)
}
}
}
}, [crimes, filterCategory, year, month, crimeDataByDistrict, focusedDistrictId])
useEffect(() => {
if (!map || !layersAdded.current || !persistentFocusedDistrictRef.current) return
console.log("Filter changed, maintaining focus for district:", persistentFocusedDistrictRef.current)
if (focusedDistrictId !== persistentFocusedDistrictRef.current) {
setFocusedDistrictId(persistentFocusedDistrictRef.current)
}
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 (!rotationAnimationRef.current && persistentFocusedDistrictRef.current) {
const startRotation = () => {
const rotationSpeed = 0.05
const animate = () => {
if (!map || !map.getMap() || !persistentFocusedDistrictRef.current) {
if (rotationAnimationRef.current) {
cancelAnimationFrame(rotationAnimationRef.current)
rotationAnimationRef.current = null
}
return
}
bearingRef.current = (bearingRef.current + rotationSpeed) % 360
map.getMap().setBearing(bearingRef.current)
rotationAnimationRef.current = requestAnimationFrame(animate)
}
rotationAnimationRef.current = requestAnimationFrame(animate)
}
startRotation()
}
if (map.getMap().getLayer("district-extrusion")) {
map.getMap().setFilter("district-extrusion", ["==", ["get", "kode_kec"], persistentFocusedDistrictRef.current])
map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-color", [
"case",
["has", "kode_kec"],
[
"match",
["get", "kode_kec"],
persistentFocusedDistrictRef.current,
crimeDataByDistrict[persistentFocusedDistrictRef.current]?.level === "low"
? CRIME_RATE_COLORS.low
: crimeDataByDistrict[persistentFocusedDistrictRef.current]?.level === "medium"
? CRIME_RATE_COLORS.medium
: crimeDataByDistrict[persistentFocusedDistrictRef.current]?.level === "high"
? CRIME_RATE_COLORS.high
: CRIME_RATE_COLORS.default,
"transparent",
],
"transparent",
])
map.getMap().setPaintProperty("district-extrusion", "fill-extrusion-height", [
"case",
["has", "kode_kec"],
["match", ["get", "kode_kec"], persistentFocusedDistrictRef.current, 800, 0],
0,
])
}
if (map.getPitch() !== 75) {
map.easeTo({
pitch: 75,
duration: 500,
})
}
}, [map, year, month, filterCategory, crimes, focusedDistrictId, crimeDataByDistrict])
}, [crimes, filterCategory, year, month])
useEffect(() => {
return () => {
@ -1217,4 +1030,3 @@ export default function DistrictLayer({
</>
)
}

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}
// />
// </>
// )
// }
// </>
// );
// }