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:
parent
4623fac52b
commit
7d01547a02
|
@ -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,26 +61,20 @@ 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
|
|
||||||
frameSkipCountRef.current++
|
|
||||||
|
|
||||||
if (frameSkipCountRef.current > frameSkipThreshold || !enablePerformanceMode) {
|
|
||||||
frameSkipCountRef.current = 0
|
|
||||||
|
|
||||||
const elapsed = timestamp - lastUpdateTimeRef.current
|
const elapsed = timestamp - lastUpdateTimeRef.current
|
||||||
const progressIncrement = elapsed / autoPlaySpeed
|
const progressIncrement = elapsed / autoPlaySpeed
|
||||||
|
|
||||||
|
@ -107,26 +101,21 @@ export function CrimeTimelapse({
|
||||||
}
|
}
|
||||||
|
|
||||||
setProgress(newProgress)
|
setProgress(newProgress)
|
||||||
|
|
||||||
// Notify parent component only after calculating all changes
|
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange(newYear, newMonth, newProgress)
|
onChange(newYear, newMonth, newProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
lastUpdateTimeRef.current = timestamp
|
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
|
|
||||||
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
|
// Create year markers
|
||||||
// Contoh: const YearMarker = React.memo(({ year, isActive }) => { ... })
|
const yearMarkers = []
|
||||||
|
for (let year = startYear; year <= endYear; year++) {
|
||||||
|
yearMarkers.push(year)
|
||||||
|
}
|
||||||
|
|
||||||
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
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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 }
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
@ -54,7 +54,6 @@ export interface DistrictLayerProps {
|
||||||
filterCategory: string | "all"
|
filterCategory: string | "all"
|
||||||
crimes: ICrimes[]
|
crimes: ICrimes[]
|
||||||
tilesetId?: string
|
tilesetId?: string
|
||||||
isTimelapsePlaying?: boolean // Add new prop to track timeline playing state
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DistrictLayer({
|
export default function DistrictLayer({
|
||||||
|
@ -65,7 +64,6 @@ export default function DistrictLayer({
|
||||||
filterCategory = "all",
|
filterCategory = "all",
|
||||||
crimes = [],
|
crimes = [],
|
||||||
tilesetId = MAPBOX_TILESET_ID,
|
tilesetId = MAPBOX_TILESET_ID,
|
||||||
isTimelapsePlaying = false, // Default to false
|
|
||||||
}: DistrictLayerProps) {
|
}: DistrictLayerProps) {
|
||||||
const { current: map } = useMap()
|
const { current: map } = useMap()
|
||||||
|
|
||||||
|
@ -78,11 +76,9 @@ export default function DistrictLayer({
|
||||||
const selectedDistrictRef = useRef<DistrictFeature | null>(null)
|
const selectedDistrictRef = useRef<DistrictFeature | null>(null)
|
||||||
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
||||||
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null)
|
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null)
|
||||||
const persistentFocusedDistrictRef = useRef<string | null>(null)
|
|
||||||
const rotationAnimationRef = useRef<number | null>(null)
|
const rotationAnimationRef = useRef<number | null>(null)
|
||||||
const bearingRef = useRef(0)
|
const bearingRef = useRef(0)
|
||||||
const layersAdded = useRef(false)
|
const layersAdded = useRef(false)
|
||||||
const isFocusedMode = useRef(false)
|
|
||||||
|
|
||||||
const crimeDataByDistrict = crimes.reduce(
|
const crimeDataByDistrict = crimes.reduce(
|
||||||
(acc, crime) => {
|
(acc, crime) => {
|
||||||
|
@ -112,8 +108,6 @@ export default function DistrictLayer({
|
||||||
// If clicking the same district, deselect it
|
// If clicking the same district, deselect it
|
||||||
if (focusedDistrictId === districtId) {
|
if (focusedDistrictId === districtId) {
|
||||||
setFocusedDistrictId(null)
|
setFocusedDistrictId(null)
|
||||||
persistentFocusedDistrictRef.current = null
|
|
||||||
isFocusedMode.current = false
|
|
||||||
selectedDistrictRef.current = null
|
selectedDistrictRef.current = null
|
||||||
setSelectedDistrict(null)
|
setSelectedDistrict(null)
|
||||||
|
|
||||||
|
@ -251,8 +245,6 @@ export default function DistrictLayer({
|
||||||
|
|
||||||
selectedDistrictRef.current = district
|
selectedDistrictRef.current = district
|
||||||
setFocusedDistrictId(district.id)
|
setFocusedDistrictId(district.id)
|
||||||
persistentFocusedDistrictRef.current = district.id
|
|
||||||
isFocusedMode.current = true
|
|
||||||
console.log("District clicked, selectedDistrictRef set to:", selectedDistrictRef.current)
|
console.log("District clicked, selectedDistrictRef set to:", selectedDistrictRef.current)
|
||||||
|
|
||||||
// Hide clusters when focusing on a district
|
// Hide clusters when focusing on a district
|
||||||
|
@ -389,9 +381,7 @@ export default function DistrictLayer({
|
||||||
console.log("Closing district popup")
|
console.log("Closing district popup")
|
||||||
selectedDistrictRef.current = null
|
selectedDistrictRef.current = null
|
||||||
setSelectedDistrict(null)
|
setSelectedDistrict(null)
|
||||||
setFocusedDistrictId(null)
|
setFocusedDistrictId(null) // Clear the focus when popup is closed
|
||||||
persistentFocusedDistrictRef.current = null
|
|
||||||
isFocusedMode.current = false
|
|
||||||
|
|
||||||
// Cancel rotation animation when closing popup
|
// Cancel rotation animation when closing popup
|
||||||
if (rotationAnimationRef.current) {
|
if (rotationAnimationRef.current) {
|
||||||
|
@ -885,37 +875,6 @@ export default function DistrictLayer({
|
||||||
if (!map || !map.getMap().getSource("crime-incidents")) return
|
if (!map || !map.getMap().getSource("crime-incidents")) return
|
||||||
|
|
||||||
try {
|
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) => {
|
const allIncidents = crimes.flatMap((crime) => {
|
||||||
if (!crime.crime_incidents) return []
|
if (!crime.crime_incidents) return []
|
||||||
|
|
||||||
|
@ -952,76 +911,14 @@ export default function DistrictLayer({
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update the source with detailed data
|
|
||||||
; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
||||||
type: "FeatureCollection",
|
type: "FeatureCollection",
|
||||||
features: allIncidents as GeoJSON.Feature[],
|
features: allIncidents as GeoJSON.Feature[],
|
||||||
})
|
})
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating incident data:", error)
|
console.error("Error updating incident data:", error)
|
||||||
}
|
}
|
||||||
}, [map, crimes, filterCategory, isTimelapsePlaying])
|
}, [map, crimes, filterCategory])
|
||||||
|
|
||||||
// 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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDistrictRef.current) {
|
if (selectedDistrictRef.current) {
|
||||||
|
@ -1088,7 +985,6 @@ export default function DistrictLayer({
|
||||||
crime_incidents,
|
crime_incidents,
|
||||||
selectedYear: year,
|
selectedYear: year,
|
||||||
selectedMonth: month,
|
selectedMonth: month,
|
||||||
isFocused: true, // Ensure this stays true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedDistrictRef.current = updatedDistrict
|
selectedDistrictRef.current = updatedDistrict
|
||||||
|
@ -1103,92 +999,9 @@ export default function DistrictLayer({
|
||||||
}
|
}
|
||||||
return updatedDistrict
|
return updatedDistrict
|
||||||
})
|
})
|
||||||
|
|
||||||
if (districtId === persistentFocusedDistrictRef.current && !focusedDistrictId) {
|
|
||||||
setFocusedDistrictId(districtId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}, [crimes, filterCategory, year, month])
|
||||||
}, [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])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -1217,4 +1030,3 @@ export default function DistrictLayer({
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
// />
|
||||||
|
// </>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// </>
|
||||||
|
// );
|
||||||
|
// }
|
Loading…
Reference in New Issue