260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from "react"
|
|
import { Pause, Play } from "lucide-react"
|
|
import { Button } from "@/app/_components/ui/button"
|
|
import { cn } from "@/app/_lib/utils"
|
|
import { Slider } from "@/app/_components/ui/slider"
|
|
import { getMonthName } from "@/app/_utils/common"
|
|
|
|
interface CrimeTimelapseProps {
|
|
startYear: number
|
|
endYear: number
|
|
onChange?: (year: number, month: number, progress: number) => void
|
|
onPlayingChange?: (isPlaying: boolean) => void
|
|
className?: string
|
|
autoPlay?: boolean
|
|
autoPlaySpeed?: number // Time to progress through one month in ms
|
|
enablePerformanceMode?: boolean // Flag untuk mode performa
|
|
}
|
|
|
|
export function CrimeTimelapse({
|
|
startYear = 2020,
|
|
endYear = 2024,
|
|
onChange,
|
|
onPlayingChange,
|
|
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)
|
|
const [progress, setProgress] = useState<number>(0) // Progress within the current month
|
|
const [isPlaying, setIsPlaying] = useState<boolean>(autoPlay)
|
|
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
|
|
|
|
// Hitung total bulan dari awal hingga akhir tahun (memoisasi)
|
|
const totalMonths = useRef(((endYear - startYear) * 12) + 12) // +12 untuk memasukkan semua bulan tahun akhir
|
|
|
|
// Menggunakan useCallback untuk fungsi yang sering dipanggil
|
|
const calculateOverallProgress = useCallback((): number => {
|
|
const yearDiff = currentYear - startYear
|
|
const monthProgress = (yearDiff * 12) + (currentMonth - 1)
|
|
return ((monthProgress + progress) / (totalMonths.current - 1)) * 100
|
|
}, [currentYear, currentMonth, progress, startYear, totalMonths])
|
|
|
|
const calculateTimeFromProgress = useCallback((overallProgress: number): { year: number; month: number; progress: number } => {
|
|
const totalProgress = (overallProgress * (totalMonths.current - 1)) / 100
|
|
const monthsFromStart = Math.floor(totalProgress)
|
|
|
|
const year = startYear + Math.floor(monthsFromStart / 12)
|
|
const month = (monthsFromStart % 12) + 1 // 1-12 for months
|
|
const monthProgress = totalProgress - Math.floor(totalProgress)
|
|
|
|
return {
|
|
year: Math.min(year, endYear),
|
|
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])
|
|
|
|
// Optimasi animasi dengan throttling berbasis requestAnimationFrame
|
|
const animate = useCallback((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
|
|
|
|
let newProgress = progress + progressIncrement
|
|
let newMonth = currentMonth
|
|
let newYear = currentYear
|
|
|
|
if (newProgress >= 1) {
|
|
newProgress = 0
|
|
newMonth = currentMonth + 1
|
|
|
|
if (newMonth > 12) {
|
|
newMonth = 1
|
|
newYear = currentYear + 1
|
|
|
|
if (newYear > endYear) {
|
|
newYear = startYear
|
|
newMonth = 1
|
|
}
|
|
}
|
|
|
|
setCurrentMonth(newMonth)
|
|
setCurrentYear(newYear)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
return () => {
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current)
|
|
}
|
|
}
|
|
}, [isPlaying, animate])
|
|
|
|
// Memoized handler for play/pause
|
|
const handlePlayPause = useCallback(() => {
|
|
setIsPlaying(prevState => {
|
|
const newPlayingState = !prevState
|
|
if (onPlayingChange) {
|
|
onPlayingChange(newPlayingState)
|
|
}
|
|
return newPlayingState
|
|
})
|
|
}, [onPlayingChange])
|
|
|
|
// Memoized handler for slider change
|
|
const handleSliderChange = useCallback((value: number[]) => {
|
|
const overallProgress = value[0]
|
|
const { year, month, progress } = calculateTimeFromProgress(overallProgress)
|
|
|
|
setCurrentYear(year)
|
|
setCurrentMonth(month)
|
|
setProgress(progress)
|
|
|
|
if (onChange) {
|
|
onChange(year, month, progress)
|
|
}
|
|
}, [calculateTimeFromProgress, onChange])
|
|
|
|
const handleSliderDragStart = useCallback(() => {
|
|
setIsDragging(true)
|
|
if (onPlayingChange) {
|
|
onPlayingChange(true) // Treat dragging as a form of "playing" for performance optimization
|
|
}
|
|
}, [onPlayingChange])
|
|
|
|
const handleSliderDragEnd = useCallback(() => {
|
|
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 }) => { ... })
|
|
|
|
return (
|
|
<div className={cn("w-full bg-transparent text-emerald-500", className)}>
|
|
<div className="relative">
|
|
{/* Current month/year marker that moves with the slider */}
|
|
<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}
|
|
</div>
|
|
|
|
{/* Wrap button and slider in their container */}
|
|
<div className="px-2 flex gap-x-2">
|
|
{/* Play/Pause button */}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handlePlayPause}
|
|
className={cn(
|
|
"text-background rounded-full hover:text-background h-10 w-10 z-10 transition-colors duration-300 bg-emerald-500 hover:bg-emerald-500/50",
|
|
)}
|
|
>
|
|
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
|
</Button>
|
|
|
|
{/* Slider */}
|
|
<Slider
|
|
value={[calculateOverallProgress()]}
|
|
min={0}
|
|
max={100}
|
|
step={0.01}
|
|
onValueChange={handleSliderChange}
|
|
onValueCommit={handleSliderDragEnd}
|
|
onPointerDown={handleSliderDragStart}
|
|
className="w-full [&>span:first-child]:h-1.5 [&>span:first-child]:bg-white/30 [&_[role=slider]]:bg-emerald-500 [&_[role=slider]]:w-3 [&_[role=slider]]:h-3 [&_[role=slider]]:border-0 [&>span:first-child_span]:bg-emerald-500 [&_[role=slider]:focus-visible]:ring-0 [&_[role=slider]:focus-visible]:ring-offset-0 [&_[role=slider]:focus-visible]:scale-105 [&_[role=slider]:focus-visible]:transition-transform"
|
|
/>
|
|
</div>
|
|
|
|
{/* Year markers */}
|
|
<div className="flex items-center relative h-10">
|
|
<div className="absolute inset-0 h-full flex">
|
|
{yearMarkers.current.map((year, index) => (
|
|
<div
|
|
key={year}
|
|
className={cn(
|
|
"flex-1 h-full flex items-center justify-center relative",
|
|
index < yearMarkers.current.length - 1 && ""
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"text-sm transition-colors font-medium",
|
|
year === currentYear ? "text-emerald-500 font-bold text-lg" : "text-white/50"
|
|
)}
|
|
>
|
|
{year}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
} |