diff --git a/sigap-website/app/_components/map/controls/year-timeline.tsx b/sigap-website/app/_components/map/controls/year-timeline.tsx new file mode 100644 index 0000000..295ea16 --- /dev/null +++ b/sigap-website/app/_components/map/controls/year-timeline.tsx @@ -0,0 +1,215 @@ +import { useState, useEffect, useRef } from "react" +import { Pause, Play } from "lucide-react" +import { Button } from "@/app/_components/ui/button" +import { cn } from "@/app/_lib/utils" +import { Slider } from "@/app/_components/ui/slider" +import { getMonthName } from "@/app/_utils/common" + +interface SmoothYearTimelineProps { + startYear: number + endYear: number + onChange?: (year: number, month: number, progress: number) => void + className?: string + autoPlay?: boolean + autoPlaySpeed?: number // Time to progress through one month in ms +} + +export function SmoothYearTimeline({ + startYear = 2020, + endYear = 2024, + onChange, + className, + autoPlay = true, + autoPlaySpeed = 1000, // Speed of month progress +}: SmoothYearTimelineProps) { + const [currentYear, setCurrentYear] = useState(startYear) + const [currentMonth, setCurrentMonth] = useState(1) // Start at January (1) + const [progress, setProgress] = useState(0) // Progress within the current month + const [isPlaying, setIsPlaying] = useState(autoPlay) + const [isDragging, setIsDragging] = useState(false) + const animationRef = useRef(null) + const lastUpdateTimeRef = useRef(0) + + // Calculate total months from start to end year + const totalMonths = ((endYear - startYear) * 12) + 12 // +12 to include all months of end year + + const calculateOverallProgress = (): number => { + const yearDiff = currentYear - startYear + const monthProgress = (yearDiff * 12) + (currentMonth - 1) + return ((monthProgress + progress) / (totalMonths - 1)) * 100 + } + + const calculateTimeFromProgress = (overallProgress: number): { year: number; month: number; progress: number } => { + const totalProgress = (overallProgress * (totalMonths - 1)) / 100 + const monthsFromStart = Math.floor(totalProgress) + + const year = startYear + Math.floor(monthsFromStart / 12) + 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 + } + } + + // Calculate the current position for the active marker + const calculateMarkerPosition = (): string => { + const overallProgress = calculateOverallProgress() + return `${overallProgress}%` + } + + const animate = (timestamp: number) => { + if (!lastUpdateTimeRef.current) { + lastUpdateTimeRef.current = timestamp + } + + if (!isDragging) { + 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) + if (onChange) { + onChange(newYear, newMonth, newProgress) + } + + lastUpdateTimeRef.current = timestamp + } + + if (isPlaying) { + animationRef.current = requestAnimationFrame(animate) + } + } + + useEffect(() => { + if (isPlaying) { + lastUpdateTimeRef.current = 0 + animationRef.current = requestAnimationFrame(animate) + } else if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current) + } + } + }, [isPlaying, currentYear, currentMonth, progress, isDragging]) + + const handlePlayPause = () => { + setIsPlaying(!isPlaying) + } + + const handleSliderChange = (value: number[]) => { + const overallProgress = value[0] + const { year, month, progress } = calculateTimeFromProgress(overallProgress) + + setCurrentYear(year) + setCurrentMonth(month) + setProgress(progress) + + if (onChange) { + onChange(year, month, progress) + } + } + + const handleSliderDragStart = () => { + setIsDragging(true) + } + + const handleSliderDragEnd = () => { + setIsDragging(false) + } + + // Create year markers + const yearMarkers = [] + for (let year = startYear; year <= endYear; year++) { + yearMarkers.push(year) + } + + return ( +
+
+ {/* Current month/year marker that moves with the slider */} +
+ {getMonthName(currentMonth)} {currentYear} +
+ + {/* Wrap button and slider in their container */} +
+ {/* Play/Pause button */} + + + {/* Slider */} + +
+ + {/* Year markers */} +
+
+ {yearMarkers.map((year, index) => ( +
+
+ {year} +
+
+ ))} +
+
+
+
+ ) +} diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index 2e932f4..d55cbea 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -20,6 +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" // Updated CrimeIncident type to match the structure in crime_incidents interface CrimeIncident { @@ -44,6 +45,7 @@ export default function CrimeMap() { const [selectedYear, setSelectedYear] = useState(2024) const [selectedMonth, setSelectedMonth] = useState("all") const [activeControl, setActiveControl] = useState("incidents") + const [yearProgress, setYearProgress] = useState(0) const mapContainerRef = useRef(null) @@ -174,6 +176,13 @@ export default function CrimeMap() { setSelectedDistrict(feature); } + // Handle year-month timeline change + const handleTimelineChange = useCallback((year: number, month: number, progress: number) => { + setSelectedYear(year) + setSelectedMonth(month) + setYearProgress(progress) + }, []) + // Reset filters const resetFilters = useCallback(() => { setSelectedYear(2024) @@ -295,8 +304,21 @@ export default function CrimeMap() { /> + )} + + {isFullscreen && ( +
+ +
+ )} diff --git a/sigap-website/app/_components/map/layers/district-layer.tsx b/sigap-website/app/_components/map/layers/district-layer.tsx index 96da11f..b627356 100644 --- a/sigap-website/app/_components/map/layers/district-layer.tsx +++ b/sigap-website/app/_components/map/layers/district-layer.tsx @@ -2,11 +2,11 @@ import { useEffect, useState, useRef, useCallback } from "react" import { useMap } from "react-map-gl/mapbox" -import { CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from "@/app/_utils/const/map" +import { BASE_BEARING, BASE_PITCH, BASE_ZOOM, CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from "@/app/_utils/const/map" import { $Enums } from "@prisma/client" import DistrictPopup from "../pop-up/district-popup" -import { ICrimes } from "@/app/_utils/types/crimes" +import type { ICrimes } from "@/app/_utils/types/crimes" // Types for district properties export interface DistrictFeature { @@ -42,6 +42,7 @@ export interface DistrictFeature { }> selectedYear?: string selectedMonth?: string + isFocused?: boolean // Add a property to track if district is focused } // District layer props @@ -74,8 +75,12 @@ export default function DistrictLayer({ const selectedDistrictRef = useRef(null) const [selectedDistrict, setSelectedDistrict] = useState(null) - + const [focusedDistrictId, setFocusedDistrictId] = useState(null) + const persistentFocusedDistrictRef = useRef(null) + const rotationAnimationRef = useRef(null) + const bearingRef = useRef(0) const layersAdded = useRef(false) + const isFocusedMode = useRef(false) const crimeDataByDistrict = crimes.reduce( (acc, crime) => { @@ -91,16 +96,52 @@ export default function DistrictLayer({ ) const handleDistrictClick = (e: any) => { - const incidentFeatures = map?.queryRenderedFeatures(e.point, { layers: ["unclustered-point", "clusters"] }); + const incidentFeatures = map?.queryRenderedFeatures(e.point, { layers: ["unclustered-point", "clusters"] }) if (incidentFeatures && incidentFeatures.length > 0) { - return; + return } if (!map || !e.features || e.features.length === 0) return const feature = e.features[0] const districtId = feature.properties.kode_kec + + // If clicking the same district, deselect it + if (focusedDistrictId === districtId) { + setFocusedDistrictId(null) + persistentFocusedDistrictRef.current = null + isFocusedMode.current = false + selectedDistrictRef.current = null + setSelectedDistrict(null) + + // Reset animation and map view when deselecting + if (rotationAnimationRef.current) { + cancelAnimationFrame(rotationAnimationRef.current) + rotationAnimationRef.current = null + } + + bearingRef.current = 0 + + // Reset pitch and bearing with animation + map.easeTo({ + pitch: BASE_PITCH, + bearing: 0, + duration: 1500, + easing: (t) => t * (2 - t), // easeOutQuad + }) + + // Show all clusters again when unfocusing + if (map.getMap().getLayer("clusters")) { + map.getMap().setLayoutProperty("clusters", "visibility", "visible") + } + if (map.getMap().getLayer("unclustered-point")) { + map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible") + } + + return + } + const crimeData = crimeDataByDistrict[districtId] || {} let crime_incidents: Array<{ @@ -115,11 +156,11 @@ export default function DistrictLayer({ longitude: number }> = [] - const districtCrimes = crimes.filter(crime => crime.district_id === districtId) + const districtCrimes = crimes.filter((crime) => crime.district_id === districtId) - districtCrimes.forEach(crimeRecord => { + districtCrimes.forEach((crimeRecord) => { if (crimeRecord && crimeRecord.crime_incidents) { - const incidents = crimeRecord.crime_incidents.map(incident => ({ + const incidents = crimeRecord.crime_incidents.map((incident) => ({ id: incident.id, timestamp: incident.timestamp, description: incident.description || "", @@ -128,7 +169,7 @@ export default function DistrictLayer({ type: incident.crime_categories?.type || "", address: incident.locations?.address || "", latitude: incident.locations?.latitude || 0, - longitude: incident.locations?.longitude || 0 + longitude: incident.locations?.longitude || 0, })) crime_incidents = [...crime_incidents, ...incidents] @@ -137,32 +178,29 @@ export default function DistrictLayer({ const firstDistrictCrime = districtCrimes.length > 0 ? districtCrimes[0] : null - const selectedYearNum = year ? parseInt(year) : new Date().getFullYear(); + const selectedYearNum = year ? Number.parseInt(year) : new Date().getFullYear() - let demographics = firstDistrictCrime?.districts.demographics?.find( - d => d.year === selectedYearNum - ); + let demographics = firstDistrictCrime?.districts.demographics?.find((d) => d.year === selectedYearNum) if (!demographics && firstDistrictCrime?.districts.demographics?.length) { - demographics = firstDistrictCrime.districts.demographics - .sort((a, b) => b.year - a.year)[0]; - console.log(`Tidak ada data demografis untuk tahun ${selectedYearNum}, menggunakan data tahun ${demographics.year}`); + demographics = firstDistrictCrime.districts.demographics.sort((a, b) => b.year - a.year)[0] + console.log( + `Tidak ada data demografis untuk tahun ${selectedYearNum}, menggunakan data tahun ${demographics.year}`, + ) } - let geographics = firstDistrictCrime?.districts.geographics?.find( - g => g.year === selectedYearNum - ); + let geographics = firstDistrictCrime?.districts.geographics?.find((g) => g.year === selectedYearNum) if (!geographics && firstDistrictCrime?.districts.geographics?.length) { const validGeographics = firstDistrictCrime.districts.geographics - .filter(g => g.year !== null) - .sort((a, b) => (b.year || 0) - (a.year || 0)); + .filter((g) => g.year !== null) + .sort((a, b) => (b.year || 0) - (a.year || 0)) - geographics = validGeographics.length > 0 ? - validGeographics[0] : - firstDistrictCrime.districts.geographics[0]; + geographics = validGeographics.length > 0 ? validGeographics[0] : firstDistrictCrime.districts.geographics[0] - console.log(`Tidak ada data geografis untuk tahun ${selectedYearNum}, menggunakan data ${geographics.year ? `tahun ${geographics.year}` : 'tanpa tahun'}`); + console.log( + `Tidak ada data geografis untuk tahun ${selectedYearNum}, menggunakan data ${geographics.year ? `tahun ${geographics.year}` : "tanpa tahun"}`, + ) } const clickLng = e.lngLat ? e.lngLat.lng : null @@ -181,7 +219,6 @@ export default function DistrictLayer({ const district: DistrictFeature = { id: districtId, name: feature.properties.nama || feature.properties.kecamatan || "Unknown District", - // properties: feature.properties, longitude: geographics.longitude || clickLng || 0, latitude: geographics.latitude || clickLat || 0, number_of_crime: crimeData.number_of_crime || 0, @@ -201,99 +238,192 @@ export default function DistrictLayer({ }, crime_incidents: crime_incidents || [], selectedYear: year, - selectedMonth: month + selectedMonth: month, + isFocused: true, // Mark this district as focused } if (!district.longitude || !district.latitude) { - console.error("Invalid district coordinates:", district); - return; + console.error("Invalid district coordinates:", district) + return } - selectedDistrictRef.current = district; - console.log("District clicked, selectedDistrictRef set to:", selectedDistrictRef.current); + selectedDistrictRef.current = district + setFocusedDistrictId(district.id) + persistentFocusedDistrictRef.current = district.id + isFocusedMode.current = true + console.log("District clicked, selectedDistrictRef set to:", selectedDistrictRef.current) + + // Hide clusters when focusing on a district + if (map.getMap().getLayer("clusters")) { + map.getMap().setLayoutProperty("clusters", "visibility", "none") + } + if (map.getMap().getLayer("unclustered-point")) { + map.getMap().setLayoutProperty("unclustered-point", "visibility", "none") + } + + // Reset bearing before animation + bearingRef.current = 0 + + // Animate to a pitched view focused on the district + map.flyTo({ + center: [district.longitude, district.latitude], + zoom: 12.5, + pitch: 75, + bearing: 0, + duration: 1500, + easing: (t) => t * (2 - t), // easeOutQuad + }) + + // Stop any existing rotation animation + if (rotationAnimationRef.current) { + cancelAnimationFrame(rotationAnimationRef.current) + rotationAnimationRef.current = null + } + + // Improved continuous bearing rotation function + const startRotation = () => { + const rotationSpeed = 0.05 // degrees per frame - adjust for slower/faster rotation + + const animate = () => { + // Check if map and focus are still valid + if (!map || !map.getMap() || focusedDistrictId !== district.id) { + if (rotationAnimationRef.current) { + cancelAnimationFrame(rotationAnimationRef.current) + rotationAnimationRef.current = null + } + return + } + + // Update bearing with smooth increment + bearingRef.current = (bearingRef.current + rotationSpeed) % 360 + map.getMap().setBearing(bearingRef.current) // Use map.getMap().setBearing instead of map.setBearing + + // Continue the animation + rotationAnimationRef.current = requestAnimationFrame(animate) + } + + // Start the animation loop after a short delay to ensure the flyTo has started + setTimeout(() => { + rotationAnimationRef.current = requestAnimationFrame(animate) + }, 100) + } + + // Start rotation after the initial flyTo completes + setTimeout(startRotation, 1600) if (onClick) { - onClick(district); + onClick(district) } else { - setSelectedDistrict(district); + setSelectedDistrict(district) } } - const handleIncidentClick = useCallback((e: any) => { - if (!map) return; + const handleIncidentClick = useCallback( + (e: any) => { + if (!map) return - const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] }) - if (!features || features.length === 0) return + const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] }) + if (!features || features.length === 0) return - const incident = features[0] - if (!incident.properties) return + const incident = features[0] + if (!incident.properties) return - e.originalEvent.stopPropagation() - e.preventDefault() + 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(), - } + 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); + console.log("Incident clicked:", incidentDetails) - const customEvent = new CustomEvent('incident_click', { - detail: incidentDetails, - bubbles: true - }) + const customEvent = new CustomEvent("incident_click", { + detail: incidentDetails, + bubbles: true, + }) - if (map.getMap().getCanvas()) { - map.getMap().getCanvas().dispatchEvent(customEvent) - } else { - document.dispatchEvent(customEvent) - } - }, [map]); + if (map.getMap().getCanvas()) { + map.getMap().getCanvas().dispatchEvent(customEvent) + } else { + document.dispatchEvent(customEvent) + } + }, + [map], + ) + const handleClusterClick = useCallback( + (e: any) => { + if (!map) return - const handleClusterClick = useCallback((e: any) => { - if (!map) return; + e.originalEvent.stopPropagation() + e.preventDefault() - e.originalEvent.stopPropagation() - e.preventDefault() + const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] }) - const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] }) + if (!features || features.length === 0) return - 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 - 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.easeTo({ + center: (features[0].geometry as any).coordinates, + zoom: zoom ?? undefined, + }) }) - }, - ) - }, [map]); + }, + [map], + ) const handleCloseDistrictPopup = useCallback(() => { - console.log("Closing district popup"); - selectedDistrictRef.current = null; - setSelectedDistrict(null); - }, []); + console.log("Closing district popup") + selectedDistrictRef.current = null + setSelectedDistrict(null) + setFocusedDistrictId(null) + persistentFocusedDistrictRef.current = null + isFocusedMode.current = false + + // Cancel rotation animation when closing popup + if (rotationAnimationRef.current) { + cancelAnimationFrame(rotationAnimationRef.current) + rotationAnimationRef.current = null + } + + bearingRef.current = 0 + + // Reset pitch and bearing + if (map) { + map.easeTo({ + zoom: BASE_ZOOM, + pitch: BASE_PITCH, + bearing: BASE_BEARING, + duration: 1500, + easing: (t) => t * (2 - t), // easeOutQuad + }) + + // Show all clusters again when closing popup + if (map.getMap().getLayer("clusters")) { + map.getMap().setLayoutProperty("clusters", "visibility", "visible") + } + if (map.getMap().getLayer("unclustered-point")) { + map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible") + } + } + }, [map]) useEffect(() => { - if (!map || !visible) return; + if (!map || !visible) return const onStyleLoad = () => { - if (!map) return; + if (!map) return try { if (!map.getMap().getSource("districts")) { @@ -317,19 +447,33 @@ export default function DistrictLayer({ [ "match", ["get", "kode_kec"], - ...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { - 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, + ...(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)", ] - }), - CRIME_RATE_COLORS.default, + : Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { + 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, + ] + })), + focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default, ], CRIME_RATE_COLORS.default, ] @@ -367,14 +511,54 @@ export default function DistrictLayer({ ) } + 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, + ) + } + if (crimes.length > 0 && !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 + (incident) => incident.crime_categories.name === filterCategory, ) } @@ -414,29 +598,13 @@ export default function DistrictLayer({ 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-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": "visible", - } + visibility: "visible", + }, }, firstSymbolId, ) @@ -473,14 +641,13 @@ export default function DistrictLayer({ "circle-stroke-color": "#fff", }, layout: { - "visibility": "visible", - } + visibility: "visible", + }, }, firstSymbolId, ) } - // Add improved mouse interaction for clusters and points map.on("mouseenter", "clusters", () => { map.getCanvas().style.cursor = "pointer" }) @@ -497,7 +664,6 @@ export default function DistrictLayer({ map.getCanvas().style.cursor = "" }) - // Also add hover effect for district fill map.on("mouseenter", "district-fill", () => { map.getCanvas().style.cursor = "pointer" }) @@ -506,20 +672,18 @@ export default function DistrictLayer({ map.getCanvas().style.cursor = "" }) - // Remove old event listeners to avoid duplicates - map.off("click", "clusters", handleClusterClick); - map.off("click", "unclustered-point", handleIncidentClick); + map.off("click", "clusters", handleClusterClick) + map.off("click", "unclustered-point", handleIncidentClick) - // Add event listeners - map.on("click", "clusters", handleClusterClick); - map.on("click", "unclustered-point", handleIncidentClick); + map.on("click", "clusters", handleClusterClick) + map.on("click", "unclustered-point", handleIncidentClick) - map.off("click", "district-fill", handleDistrictClick); - map.on("click", "district-fill", handleDistrictClick); + map.off("click", "district-fill", handleDistrictClick) + map.on("click", "district-fill", handleDistrictClick) - map.on("mouseleave", "district-fill", () => setHoverInfo(null)); + map.on("mouseleave", "district-fill", () => setHoverInfo(null)) - layersAdded.current = true; + layersAdded.current = true } } else { if (map.getMap().getLayer("district-fill")) { @@ -529,19 +693,33 @@ export default function DistrictLayer({ [ "match", ["get", "kode_kec"], - ...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { - 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, + ...(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)", ] - }), - CRIME_RATE_COLORS.default, + : Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { + 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, + ] + })), + focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default, ], CRIME_RATE_COLORS.default, ] as any) @@ -550,45 +728,56 @@ export default function DistrictLayer({ } catch (error) { console.error("Error adding district layers:", error) } - }; + } if (map.isStyleLoaded()) { - onStyleLoad(); + onStyleLoad() } else { - map.once("style.load", onStyleLoad); + map.once("style.load", onStyleLoad) } return () => { if (map) { - map.off("click", "district-fill", handleDistrictClick); + map.off("click", "district-fill", handleDistrictClick) } - }; - }, [map, visible, tilesetId, crimes, filterCategory, year, month, handleIncidentClick, handleClusterClick]); + } + }, [map, visible, tilesetId, crimes, filterCategory, year, month, handleIncidentClick, handleClusterClick]) useEffect(() => { if (!map || !layersAdded.current) return try { if (map.getMap().getLayer("district-fill")) { - const colorEntries = Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { - if (!data || !data.level) { + 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, - CRIME_RATE_COLORS.default + 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, ] - } - - 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", @@ -597,17 +786,98 @@ export default function DistrictLayer({ "match", ["get", "kode_kec"], ...colorEntries, - CRIME_RATE_COLORS.default, + focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default, ], CRIME_RATE_COLORS.default, ] as any map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression) } + + 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 district layer:", error) } - }, [map, crimes, crimeDataByDistrict]) + }, [map, crimes, crimeDataByDistrict, focusedDistrictId]) useEffect(() => { if (!map || !map.getMap().getSource("crime-incidents")) return @@ -620,43 +890,39 @@ export default function DistrictLayer({ if (filterCategory !== "all") { filteredIncidents = crime.crime_incidents.filter( - incident => incident.crime_categories && - incident.crime_categories.name === filterCategory + (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 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) - }); - - (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({ - type: "FeatureCollection", - features: allIncidents as GeoJSON.Feature[], + 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) }) - + ; (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) } @@ -664,47 +930,38 @@ export default function DistrictLayer({ useEffect(() => { if (selectedDistrictRef.current) { - const districtId = selectedDistrictRef.current.id; - const crimeData = crimeDataByDistrict[districtId] || {}; + const districtId = selectedDistrictRef.current.id + const crimeData = crimeDataByDistrict[districtId] || {} - const districtCrime = crimes.find(crime => crime.district_id === districtId); + const districtCrime = crimes.find((crime) => crime.district_id === districtId) if (districtCrime) { - const selectedYearNum = year ? parseInt(year) : new Date().getFullYear(); + const selectedYearNum = year ? Number.parseInt(year) : new Date().getFullYear() - let demographics = districtCrime.districts.demographics?.find( - d => d.year === selectedYearNum - ); + let demographics = districtCrime.districts.demographics?.find((d) => d.year === selectedYearNum) if (!demographics && districtCrime.districts.demographics?.length) { - demographics = districtCrime.districts.demographics - .sort((a, b) => b.year - a.year)[0]; + demographics = districtCrime.districts.demographics.sort((a, b) => b.year - a.year)[0] } - let geographics = districtCrime.districts.geographics?.find( - g => g.year === selectedYearNum - ); + let geographics = districtCrime.districts.geographics?.find((g) => g.year === selectedYearNum) if (!geographics && districtCrime.districts.geographics?.length) { const validGeographics = districtCrime.districts.geographics - .filter(g => g.year !== null) - .sort((a, b) => (b.year || 0) - (a.year || 0)); + .filter((g) => g.year !== null) + .sort((a, b) => (b.year || 0) - (a.year || 0)) - geographics = validGeographics.length > 0 ? - validGeographics[0] : - districtCrime.districts.geographics[0]; + geographics = validGeographics.length > 0 ? validGeographics[0] : districtCrime.districts.geographics[0] } if (!demographics || !geographics) { - console.error("Missing district data:", { demographics, geographics }); - return; + console.error("Missing district data:", { demographics, geographics }) + return } const crime_incidents = districtCrime.crime_incidents - .filter(incident => - filterCategory === "all" || incident.crime_categories.name === filterCategory - ) - .map(incident => ({ + .filter((incident) => filterCategory === "all" || incident.crime_categories.name === filterCategory) + .map((incident) => ({ id: incident.id, timestamp: incident.timestamp, description: incident.description, @@ -713,8 +970,8 @@ export default function DistrictLayer({ type: incident.crime_categories.type || "", address: incident.locations.address || "", latitude: incident.locations.latitude, - longitude: incident.locations.longitude - })); + longitude: incident.locations.longitude, + })) const updatedDistrict: DistrictFeature = { ...selectedDistrictRef.current, @@ -735,22 +992,117 @@ export default function DistrictLayer({ }, crime_incidents, selectedYear: year, - selectedMonth: month - }; + selectedMonth: month, + isFocused: true, // Ensure this stays true + } - selectedDistrictRef.current = updatedDistrict; + selectedDistrictRef.current = updatedDistrict - setSelectedDistrict(prevDistrict => { - if (prevDistrict?.id === updatedDistrict.id && + setSelectedDistrict((prevDistrict) => { + if ( + prevDistrict?.id === updatedDistrict.id && prevDistrict?.selectedYear === updatedDistrict.selectedYear && - prevDistrict?.selectedMonth === updatedDistrict.selectedMonth) { - return prevDistrict; + prevDistrict?.selectedMonth === updatedDistrict.selectedMonth + ) { + return prevDistrict } - 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(() => { + return () => { + if (rotationAnimationRef.current) { + cancelAnimationFrame(rotationAnimationRef.current) + rotationAnimationRef.current = null + } + } + }, []) if (!visible) return null diff --git a/sigap-website/app/_components/map/map.tsx b/sigap-website/app/_components/map/map.tsx index a10c88e..a1be92c 100644 --- a/sigap-website/app/_components/map/map.tsx +++ b/sigap-website/app/_components/map/map.tsx @@ -4,7 +4,7 @@ import type React from "react" import { useState, useCallback, useRef } from "react" import { type ViewState, Map, type MapRef, NavigationControl, GeolocateControl } from "react-map-gl/mapbox" import { FullscreenControl } from "react-map-gl/mapbox" -import { BASE_LATITUDE, BASE_LONGITUDE, BASE_ZOOM, MAPBOX_STYLES, type MapboxStyle } from "@/app/_utils/const/map" +import { BASE_BEARING, BASE_LATITUDE, BASE_LONGITUDE, BASE_PITCH, BASE_ZOOM, MAPBOX_STYLES, type MapboxStyle } from "@/app/_utils/const/map" import "mapbox-gl/dist/mapbox-gl.css" import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'; import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'; @@ -39,8 +39,8 @@ export default function MapView({ longitude: BASE_LONGITUDE, latitude: BASE_LATITUDE, zoom: BASE_ZOOM, - bearing: 0, - pitch: 0, + bearing: BASE_BEARING, + pitch: BASE_PITCH, ...initialViewState, } diff --git a/sigap-website/app/_components/map/pop-up/district-popup.tsx b/sigap-website/app/_components/map/pop-up/district-popup.tsx index 3ece597..3560cc6 100644 --- a/sigap-website/app/_components/map/pop-up/district-popup.tsx +++ b/sigap-website/app/_components/map/pop-up/district-popup.tsx @@ -76,13 +76,13 @@ export default function DistrictPopup({ const getCrimeRateBadge = (level?: string) => { switch (level) { case "low": - return Low + return Low case "medium": - return Medium + return Medium case "high": - return High + return High case "critical": - return Critical + return Critical default: return Unknown } diff --git a/sigap-website/app/_components/ui/slider.tsx b/sigap-website/app/_components/ui/slider.tsx new file mode 100644 index 0000000..ad4a283 --- /dev/null +++ b/sigap-website/app/_components/ui/slider.tsx @@ -0,0 +1,27 @@ +"use client" + +import * as React from "react" +import * as SliderPrimitive from "@radix-ui/react-slider" +import { cn } from "@/app/_lib/utils" + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } diff --git a/sigap-website/app/_utils/const/map.ts b/sigap-website/app/_utils/const/map.ts index 0b536e3..25a76f1 100644 --- a/sigap-website/app/_utils/const/map.ts +++ b/sigap-website/app/_utils/const/map.ts @@ -1,6 +1,6 @@ -export const MAP_STYLE = 'mapbox://styles/mapbox/dark-v11'; - export const BASE_ZOOM = 9.5; // Default zoom level for the map +export const BASE_PITCH = 0; // Default pitch for the map +export const BASE_BEARING = 0; // Default bearing for the map export const BASE_LATITUDE = -8.17; // Default latitude for the map center (Jember region) export const BASE_LONGITUDE = 113.65; // Default longitude for the map center (Jember region) export const MAPBOX_ACCESS_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN; diff --git a/sigap-website/package-lock.json b/sigap-website/package-lock.json index dc22cee..4e8cdf6 100644 --- a/sigap-website/package-lock.json +++ b/sigap-website/package-lock.json @@ -23,6 +23,7 @@ "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.3.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3", @@ -3442,6 +3443,230 @@ } } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.2.tgz", + "integrity": "sha512-oQnqfgSiYkxZ1MrF6672jw2/zZvpB+PJsrIc3Zm1zof1JHf/kj7WhmROw7JahLfOwYQ5/+Ip0rFORgF1tjSiaQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-collection": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.4.tgz", + "integrity": "sha512-cv4vSf7HttqXilDnAnvINd53OTl1/bjUYVZrkFnA7nwmY9Ob2POUy0WY0sfqBAe1s5FyKsyceQlqiEGPYNTadg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz", + "integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz", + "integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", @@ -3586,6 +3811,39 @@ } } }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-escape-keydown": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", diff --git a/sigap-website/package.json b/sigap-website/package.json index 53aa27b..b346039 100644 --- a/sigap-website/package.json +++ b/sigap-website/package.json @@ -29,6 +29,7 @@ "@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-select": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", + "@radix-ui/react-slider": "^1.3.2", "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",