feat: replace SmoothYearTimeline with CrimeTimelapse component and enhance timeline functionality

This commit is contained in:
vergiLgood1 2025-05-03 23:51:26 +07:00
parent e488bad7c1
commit 4623fac52b
3 changed files with 251 additions and 98 deletions

View File

@ -1,27 +1,31 @@
import { useState, useEffect, useRef } from "react" import { useState, useEffect, useRef, useCallback } 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"
import { Slider } from "@/app/_components/ui/slider" import { Slider } from "@/app/_components/ui/slider"
import { getMonthName } from "@/app/_utils/common" import { getMonthName } from "@/app/_utils/common"
interface SmoothYearTimelineProps { interface CrimeTimelapseProps {
startYear: number startYear: number
endYear: number endYear: number
onChange?: (year: number, month: number, progress: number) => void onChange?: (year: number, month: number, progress: number) => void
onPlayingChange?: (isPlaying: boolean) => void
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 SmoothYearTimeline({ export function CrimeTimelapse({
startYear = 2020, startYear = 2020,
endYear = 2024, endYear = 2024,
onChange, onChange,
onPlayingChange,
className, className,
autoPlay = true, autoPlay = true,
autoPlaySpeed = 1000, // Speed of month progress autoPlaySpeed = 1000, // Speed of month progress
}: SmoothYearTimelineProps) { enablePerformanceMode = true, // Default aktifkan mode performa tinggi
}: 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)
const [progress, setProgress] = useState<number>(0) // Progress within the current month const [progress, setProgress] = useState<number>(0) // Progress within the current month
@ -29,18 +33,23 @@ export function SmoothYearTimeline({
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
// Calculate total months from start to end year // Jumlah frame yang akan dilewati saat performance mode aktif
const totalMonths = ((endYear - startYear) * 12) + 12 // +12 to include all months of end year const frameSkipThreshold = enablePerformanceMode ? 3 : 0
const calculateOverallProgress = (): number => { // 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 yearDiff = currentYear - startYear
const monthProgress = (yearDiff * 12) + (currentMonth - 1) const monthProgress = (yearDiff * 12) + (currentMonth - 1)
return ((monthProgress + progress) / (totalMonths - 1)) * 100 return ((monthProgress + progress) / (totalMonths.current - 1)) * 100
} }, [currentYear, currentMonth, progress, startYear, totalMonths])
const calculateTimeFromProgress = (overallProgress: number): { year: number; month: number; progress: number } => { const calculateTimeFromProgress = useCallback((overallProgress: number): { year: number; month: number; progress: number } => {
const totalProgress = (overallProgress * (totalMonths - 1)) / 100 const totalProgress = (overallProgress * (totalMonths.current - 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)
@ -52,61 +61,72 @@ export function SmoothYearTimeline({
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 = (): string => { const calculateMarkerPosition = useCallback((): string => {
const overallProgress = calculateOverallProgress() return `${calculateOverallProgress()}%`
return `${overallProgress}%` }, [calculateOverallProgress])
}
const animate = (timestamp: number) => { // Optimasi animasi dengan throttling berbasis requestAnimationFrame
const animate = useCallback((timestamp: number) => {
if (!lastUpdateTimeRef.current) { if (!lastUpdateTimeRef.current) {
lastUpdateTimeRef.current = timestamp lastUpdateTimeRef.current = timestamp
} }
if (!isDragging) { if (!isDragging) {
const elapsed = timestamp - lastUpdateTimeRef.current // Performance optimization: skip frames in performance mode
const progressIncrement = elapsed / autoPlaySpeed frameSkipCountRef.current++
let newProgress = progress + progressIncrement if (frameSkipCountRef.current > frameSkipThreshold || !enablePerformanceMode) {
let newMonth = currentMonth frameSkipCountRef.current = 0
let newYear = currentYear
if (newProgress >= 1) { const elapsed = timestamp - lastUpdateTimeRef.current
newProgress = 0 const progressIncrement = elapsed / autoPlaySpeed
newMonth = currentMonth + 1
if (newMonth > 12) { let newProgress = progress + progressIncrement
newMonth = 1 let newMonth = currentMonth
newYear = currentYear + 1 let newYear = currentYear
if (newYear > endYear) { if (newProgress >= 1) {
newYear = startYear newProgress = 0
newMonth = currentMonth + 1
if (newMonth > 12) {
newMonth = 1 newMonth = 1
newYear = currentYear + 1
if (newYear > endYear) {
newYear = startYear
newMonth = 1
}
} }
setCurrentMonth(newMonth)
setCurrentYear(newYear)
} }
setCurrentMonth(newMonth) setProgress(newProgress)
setCurrentYear(newYear)
}
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)
@ -117,13 +137,21 @@ export function SmoothYearTimeline({
cancelAnimationFrame(animationRef.current) cancelAnimationFrame(animationRef.current)
} }
} }
}, [isPlaying, currentYear, currentMonth, progress, isDragging]) }, [isPlaying, animate])
const handlePlayPause = () => { // Memoized handler for play/pause
setIsPlaying(!isPlaying) const handlePlayPause = useCallback(() => {
} setIsPlaying(prevState => {
const newPlayingState = !prevState
if (onPlayingChange) {
onPlayingChange(newPlayingState)
}
return newPlayingState
})
}, [onPlayingChange])
const handleSliderChange = (value: number[]) => { // Memoized handler for slider change
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)
@ -134,31 +162,46 @@ export function SmoothYearTimeline({
if (onChange) { if (onChange) {
onChange(year, month, progress) onChange(year, month, progress)
} }
} }, [calculateTimeFromProgress, onChange])
const handleSliderDragStart = () => { const handleSliderDragStart = useCallback(() => {
setIsDragging(true) setIsDragging(true)
} if (onPlayingChange) {
onPlayingChange(true) // Treat dragging as a form of "playing" for performance optimization
}
}, [onPlayingChange])
const handleSliderDragEnd = () => { const handleSliderDragEnd = useCallback(() => {
setIsDragging(false) setIsDragging(false)
} if (onPlayingChange) {
onPlayingChange(isPlaying) // Restore to actual playing state
}
}, [isPlaying, onPlayingChange])
// Create year markers // Komputasi tahun marker dilakukan sekali saja dan di-cache
const yearMarkers = [] const yearMarkers = useRef<number[]>([])
for (let year = startYear; year <= endYear; year++) { useEffect(() => {
yearMarkers.push(year) 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 ( 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 */} {/* Current month/year marker that moves with the slider */}
<div <div
className="absolute bottom-full mb-2 transform -translate-x-1/2 bg-emerald-500 text-background px-3 py-1 rounded-full text-xs font-bold z-20" 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() }} style={{ left: calculateMarkerPosition() }}
> >
{getMonthName(currentMonth)} {currentYear} {isPlaying}{getMonthName(currentMonth)} {currentYear}
</div> </div>
{/* Wrap button and slider in their container */} {/* Wrap button and slider in their container */}
@ -168,7 +211,9 @@ export function SmoothYearTimeline({
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={handlePlayPause} onClick={handlePlayPause}
className="text-background bg-emerald-500 rounded-full hover:text-background hover:bg-emerald-500/50 h-10 w-10 z-10" 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" />} {isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button> </Button>
@ -189,12 +234,12 @@ export function SmoothYearTimeline({
{/* 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.map((year, index) => ( {yearMarkers.current.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.length - 1 && "" index < yearMarkers.current.length - 1 && ""
)} )}
> >
<div <div

View File

@ -20,7 +20,7 @@ import SidebarToggle from "./sidebar/sidebar-toggle"
import { cn } from "@/app/_lib/utils" import { cn } from "@/app/_lib/utils"
import CrimePopup from "./pop-up/crime-popup" import CrimePopup from "./pop-up/crime-popup"
import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client" import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client"
import { SmoothYearTimeline } from "./controls/year-timeline" import { CrimeTimelapse } from "./controls/crime-timelapse"
// Updated CrimeIncident type to match the structure in crime_incidents // Updated CrimeIncident type to match the structure in crime_incidents
interface CrimeIncident { interface CrimeIncident {
@ -46,6 +46,7 @@ export default function CrimeMap() {
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all") const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
const [activeControl, setActiveControl] = useState<ITopTooltipsMapId>("incidents") const [activeControl, setActiveControl] = useState<ITopTooltipsMapId>("incidents")
const [yearProgress, setYearProgress] = useState(0) const [yearProgress, setYearProgress] = useState(0)
const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false)
const mapContainerRef = useRef<HTMLDivElement>(null) const mapContainerRef = useRef<HTMLDivElement>(null)
@ -183,6 +184,17 @@ export default function CrimeMap() {
setYearProgress(progress) setYearProgress(progress)
}, []) }, [])
// Handle timeline playing state change
const handleTimelinePlayingChange = useCallback((playing: boolean) => {
setisTimelapsePlaying(playing)
// When timelapse starts, close any open popups/details
if (playing) {
setSelectedIncident(null)
setSelectedDistrict(null)
}
}, [])
// Reset filters // Reset filters
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
setSelectedYear(2024) setSelectedYear(2024)
@ -248,6 +260,7 @@ 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 */} {/* Pass onClick if you want to handle districts externally */}
@ -264,7 +277,6 @@ export default function CrimeMap() {
{/* Popup for selected incident */} {/* Popup for selected incident */}
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
<> <>
{/* {console.log("About to render CrimePopup with:", selectedIncident)} */}
<CrimePopup <CrimePopup
longitude={selectedIncident.longitude} longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude} latitude={selectedIncident.latitude}
@ -310,12 +322,12 @@ export default function CrimeMap() {
{isFullscreen && ( {isFullscreen && (
<div className="absolute flex w-full bottom-0"> <div className="absolute flex w-full bottom-0">
<SmoothYearTimeline <CrimeTimelapse
startYear={2020} startYear={2020}
endYear={2024} endYear={2024}
autoPlay={false} autoPlay={false}
autoPlaySpeed={1000}
onChange={handleTimelineChange} onChange={handleTimelineChange}
onPlayingChange={handleTimelinePlayingChange}
/> />
</div> </div>
)} )}

View File

@ -54,6 +54,7 @@ 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({
@ -64,6 +65,7 @@ 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()
@ -883,50 +885,143 @@ export default function DistrictLayer({
if (!map || !map.getMap().getSource("crime-incidents")) return if (!map || !map.getMap().getSource("crime-incidents")) return
try { try {
const allIncidents = crimes.flatMap((crime) => { // If timeline is playing, hide all point/cluster layers to improve performance
if (!crime.crime_incidents) return [] if (isTimelapsePlaying) {
// Hide all incident points during timelapse
let filteredIncidents = crime.crime_incidents if (map.getMap().getLayer("clusters")) {
map.getMap().setLayoutProperty("clusters", "visibility", "none")
if (filterCategory !== "all") { }
filteredIncidents = crime.crime_incidents.filter( if (map.getMap().getLayer("unclustered-point")) {
(incident) => incident.crime_categories && incident.crime_categories.name === filterCategory, map.getMap().setLayoutProperty("unclustered-point", "visibility", "none")
) }
if (map.getMap().getLayer("cluster-count")) {
map.getMap().setLayoutProperty("cluster-count", "visibility", "none")
} }
return filteredIncidents // Update the source with empty data to free up resources
.map((incident) => { ; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
if (!incident.locations) { type: "FeatureCollection",
console.warn("Missing location for incident:", incident.id) features: [],
return null })
} } 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")
}
return { // Restore detailed incidents when timelapse stops
type: "Feature" as const, const allIncidents = crimes.flatMap((crime) => {
properties: { if (!crime.crime_incidents) return []
id: incident.id,
district: crime.districts?.name || "Unknown", let filteredIncidents = crime.crime_incidents
category: incident.crime_categories?.name || "Unknown",
incidentType: incident.crime_categories?.type || "Unknown", if (filterCategory !== "all") {
level: crime.level || "low", filteredIncidents = crime.crime_incidents.filter(
description: incident.description || "", (incident) => incident.crime_categories && incident.crime_categories.name === filterCategory,
}, )
geometry: { }
type: "Point" as const,
coordinates: [incident.locations.longitude || 0, incident.locations.latitude || 0], return filteredIncidents
}, .map((incident) => {
} if (!incident.locations) {
}) console.warn("Missing location for incident:", incident.id)
.filter(Boolean) 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({ ; (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]) }, [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])
useEffect(() => { useEffect(() => {
if (selectedDistrictRef.current) { if (selectedDistrictRef.current) {
@ -1122,3 +1217,4 @@ export default function DistrictLayer({
</> </>
) )
} }