From 4623fac52bb5874e13369e070c14301d56c0b87d Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Sat, 3 May 2025 23:51:26 +0700 Subject: [PATCH] feat: replace SmoothYearTimeline with CrimeTimelapse component and enhance timeline functionality --- ...{year-timeline.tsx => crime-timelapse.tsx} | 165 +++++++++++------- .../app/_components/map/crime-map.tsx | 20 ++- .../_components/map/layers/district-layer.tsx | 164 +++++++++++++---- 3 files changed, 251 insertions(+), 98 deletions(-) rename sigap-website/app/_components/map/controls/{year-timeline.tsx => crime-timelapse.tsx} (51%) diff --git a/sigap-website/app/_components/map/controls/year-timeline.tsx b/sigap-website/app/_components/map/controls/crime-timelapse.tsx similarity index 51% rename from sigap-website/app/_components/map/controls/year-timeline.tsx rename to sigap-website/app/_components/map/controls/crime-timelapse.tsx index 295ea16..a55aeb0 100644 --- a/sigap-website/app/_components/map/controls/year-timeline.tsx +++ b/sigap-website/app/_components/map/controls/crime-timelapse.tsx @@ -1,27 +1,31 @@ -import { useState, useEffect, useRef } from "react" +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 SmoothYearTimelineProps { +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 SmoothYearTimeline({ +export function CrimeTimelapse({ startYear = 2020, endYear = 2024, onChange, + onPlayingChange, className, autoPlay = true, autoPlaySpeed = 1000, // Speed of month progress -}: SmoothYearTimelineProps) { + enablePerformanceMode = true, // Default aktifkan mode performa tinggi +}: CrimeTimelapseProps) { const [currentYear, setCurrentYear] = useState(startYear) const [currentMonth, setCurrentMonth] = useState(1) // Start at January (1) const [progress, setProgress] = useState(0) // Progress within the current month @@ -29,18 +33,23 @@ export function SmoothYearTimeline({ const [isDragging, setIsDragging] = useState(false) const animationRef = useRef(null) const lastUpdateTimeRef = useRef(0) + const frameSkipCountRef = useRef(0) // Untuk pelompatan frame - // Calculate total months from start to end year - const totalMonths = ((endYear - startYear) * 12) + 12 // +12 to include all months of end year + // Jumlah frame yang akan dilewati saat performance mode aktif + 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 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 totalProgress = (overallProgress * (totalMonths - 1)) / 100 + 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) @@ -52,61 +61,72 @@ export function SmoothYearTimeline({ month: Math.min(month, 12), progress: monthProgress } - } + }, [startYear, endYear, totalMonths]) // Calculate the current position for the active marker - const calculateMarkerPosition = (): string => { - const overallProgress = calculateOverallProgress() - return `${overallProgress}%` - } + const calculateMarkerPosition = useCallback((): string => { + return `${calculateOverallProgress()}%` + }, [calculateOverallProgress]) - const animate = (timestamp: number) => { + // Optimasi animasi dengan throttling berbasis requestAnimationFrame + const animate = useCallback((timestamp: number) => { if (!lastUpdateTimeRef.current) { lastUpdateTimeRef.current = timestamp } if (!isDragging) { - const elapsed = timestamp - lastUpdateTimeRef.current - const progressIncrement = elapsed / autoPlaySpeed + // Performance optimization: skip frames in performance mode + frameSkipCountRef.current++ - let newProgress = progress + progressIncrement - let newMonth = currentMonth - let newYear = currentYear + if (frameSkipCountRef.current > frameSkipThreshold || !enablePerformanceMode) { + frameSkipCountRef.current = 0 - if (newProgress >= 1) { - newProgress = 0 - newMonth = currentMonth + 1 + const elapsed = timestamp - lastUpdateTimeRef.current + const progressIncrement = elapsed / autoPlaySpeed - if (newMonth > 12) { - newMonth = 1 - newYear = currentYear + 1 + let newProgress = progress + progressIncrement + let newMonth = currentMonth + let newYear = currentYear - if (newYear > endYear) { - newYear = startYear + 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) } - setCurrentMonth(newMonth) - setCurrentYear(newYear) - } + setProgress(newProgress) - setProgress(newProgress) - if (onChange) { - onChange(newYear, newMonth, newProgress) - } + // Notify parent component only after calculating all changes + if (onChange) { + onChange(newYear, newMonth, newProgress) + } - lastUpdateTimeRef.current = timestamp + 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) @@ -117,13 +137,21 @@ export function SmoothYearTimeline({ cancelAnimationFrame(animationRef.current) } } - }, [isPlaying, currentYear, currentMonth, progress, isDragging]) + }, [isPlaying, animate]) - const handlePlayPause = () => { - setIsPlaying(!isPlaying) - } + // Memoized handler for play/pause + 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 { year, month, progress } = calculateTimeFromProgress(overallProgress) @@ -134,31 +162,46 @@ export function SmoothYearTimeline({ if (onChange) { onChange(year, month, progress) } - } + }, [calculateTimeFromProgress, onChange]) - const handleSliderDragStart = () => { + const handleSliderDragStart = useCallback(() => { setIsDragging(true) - } + if (onPlayingChange) { + onPlayingChange(true) // Treat dragging as a form of "playing" for performance optimization + } + }, [onPlayingChange]) - const handleSliderDragEnd = () => { + const handleSliderDragEnd = useCallback(() => { setIsDragging(false) - } + if (onPlayingChange) { + onPlayingChange(isPlaying) // Restore to actual playing state + } + }, [isPlaying, onPlayingChange]) - // Create year markers - const yearMarkers = [] - for (let year = startYear; year <= endYear; year++) { - yearMarkers.push(year) - } + // Komputasi tahun marker dilakukan sekali saja dan di-cache + const yearMarkers = useRef([]) + 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 (
{/* Current month/year marker that moves with the slider */}
- {getMonthName(currentMonth)} {currentYear} + {isPlaying}{getMonthName(currentMonth)} {currentYear}
{/* Wrap button and slider in their container */} @@ -168,7 +211,9 @@ export function SmoothYearTimeline({ variant="ghost" size="icon" 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 ? : } @@ -189,12 +234,12 @@ export function SmoothYearTimeline({ {/* Year markers */}
- {yearMarkers.map((year, index) => ( + {yearMarkers.current.map((year, index) => (
) -} +} \ No newline at end of file diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index d55cbea..1ee8c13 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -20,7 +20,7 @@ import SidebarToggle from "./sidebar/sidebar-toggle" import { cn } from "@/app/_lib/utils" import CrimePopup from "./pop-up/crime-popup" 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 interface CrimeIncident { @@ -46,6 +46,7 @@ export default function CrimeMap() { const [selectedMonth, setSelectedMonth] = useState("all") const [activeControl, setActiveControl] = useState("incidents") const [yearProgress, setYearProgress] = useState(0) + const [isTimelapsePlaying, setisTimelapsePlaying] = useState(false) const mapContainerRef = useRef(null) @@ -183,6 +184,17 @@ export default function CrimeMap() { 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 const resetFilters = useCallback(() => { setSelectedYear(2024) @@ -248,6 +260,7 @@ export default function CrimeMap() { year={selectedYear.toString()} month={selectedMonth.toString()} filterCategory={selectedCategory} + isTimelapsePlaying={isTimelapsePlaying} /> {/* Pass onClick if you want to handle districts externally */} @@ -264,7 +277,6 @@ export default function CrimeMap() { {/* Popup for selected incident */} {selectedIncident && selectedIncident.latitude && selectedIncident.longitude && ( <> - {/* {console.log("About to render CrimePopup with:", selectedIncident)} */} -
)} diff --git a/sigap-website/app/_components/map/layers/district-layer.tsx b/sigap-website/app/_components/map/layers/district-layer.tsx index b627356..a704ce2 100644 --- a/sigap-website/app/_components/map/layers/district-layer.tsx +++ b/sigap-website/app/_components/map/layers/district-layer.tsx @@ -54,6 +54,7 @@ export interface DistrictLayerProps { filterCategory: string | "all" crimes: ICrimes[] tilesetId?: string + isTimelapsePlaying?: boolean // Add new prop to track timeline playing state } export default function DistrictLayer({ @@ -64,6 +65,7 @@ export default function DistrictLayer({ filterCategory = "all", crimes = [], tilesetId = MAPBOX_TILESET_ID, + isTimelapsePlaying = false, // Default to false }: DistrictLayerProps) { const { current: map } = useMap() @@ -883,50 +885,143 @@ export default function DistrictLayer({ if (!map || !map.getMap().getSource("crime-incidents")) return try { - 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, - ) + // 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") } - return filteredIncidents - .map((incident) => { - if (!incident.locations) { - console.warn("Missing location for incident:", incident.id) - return null - } + // 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") + } - 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) - }) + // 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]) + }, [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(() => { if (selectedDistrictRef.current) { @@ -1122,3 +1217,4 @@ export default function DistrictLayer({ ) } +