feat(map): add pitch and bearing constants for map initialization
feat(district-popup): enhance badge hover styles for crime levels feat(map): implement year timeline control with smooth animation feat(ui): create a reusable slider component using Radix UI chore(package): update package.json and package-lock.json to include @radix-ui/react-slider
This commit is contained in:
parent
fdc0403b81
commit
e488bad7c1
|
@ -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<number>(startYear)
|
||||
const [currentMonth, setCurrentMonth] = useState<number>(1) // Start at January (1)
|
||||
const [progress, setProgress] = useState<number>(0) // Progress within the current month
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(autoPlay)
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false)
|
||||
const animationRef = useRef<number | null>(null)
|
||||
const lastUpdateTimeRef = useRef<number>(0)
|
||||
|
||||
// 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 (
|
||||
<div className={cn("w-full bg-transparent text-emerald-500", className)}>
|
||||
<div className="relative">
|
||||
{/* Current month/year marker that moves with the slider */}
|
||||
<div
|
||||
className="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"
|
||||
style={{ left: calculateMarkerPosition() }}
|
||||
>
|
||||
{getMonthName(currentMonth)} {currentYear}
|
||||
</div>
|
||||
|
||||
{/* Wrap button and slider in their container */}
|
||||
<div className="px-2 flex gap-x-2">
|
||||
{/* Play/Pause button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handlePlayPause}
|
||||
className="text-background bg-emerald-500 rounded-full hover:text-background hover:bg-emerald-500/50 h-10 w-10 z-10"
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</Button>
|
||||
|
||||
{/* Slider */}
|
||||
<Slider
|
||||
value={[calculateOverallProgress()]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.01}
|
||||
onValueChange={handleSliderChange}
|
||||
onValueCommit={handleSliderDragEnd}
|
||||
onPointerDown={handleSliderDragStart}
|
||||
className="w-full [&>span:first-child]:h-1.5 [&>span:first-child]:bg-white/30 [&_[role=slider]]:bg-emerald-500 [&_[role=slider]]:w-3 [&_[role=slider]]:h-3 [&_[role=slider]]:border-0 [&>span:first-child_span]:bg-emerald-500 [&_[role=slider]:focus-visible]:ring-0 [&_[role=slider]:focus-visible]:ring-offset-0 [&_[role=slider]:focus-visible]:scale-105 [&_[role=slider]:focus-visible]:transition-transform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Year markers */}
|
||||
<div className="flex items-center relative h-10">
|
||||
<div className="absolute inset-0 h-full flex">
|
||||
{yearMarkers.map((year, index) => (
|
||||
<div
|
||||
key={year}
|
||||
className={cn(
|
||||
"flex-1 h-full flex items-center justify-center relative",
|
||||
index < yearMarkers.length - 1 && ""
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm transition-colors font-medium",
|
||||
year === currentYear ? "text-emerald-500 font-bold text-lg" : "text-white/50"
|
||||
)}
|
||||
>
|
||||
{year}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -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<number>(2024)
|
||||
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
|
||||
const [activeControl, setActiveControl] = useState<ITopTooltipsMapId>("incidents")
|
||||
const [yearProgress, setYearProgress] = useState(0)
|
||||
|
||||
const mapContainerRef = useRef<HTMLDivElement>(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() {
|
|||
/>
|
||||
|
||||
<MapLegend position="bottom-right" />
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
{isFullscreen && (
|
||||
<div className="absolute flex w-full bottom-0">
|
||||
<SmoothYearTimeline
|
||||
startYear={2020}
|
||||
endYear={2024}
|
||||
autoPlay={false}
|
||||
autoPlaySpeed={1000}
|
||||
onChange={handleTimelineChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</MapView>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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<DistrictFeature | null>(null)
|
||||
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
||||
|
||||
const [focusedDistrictId, setFocusedDistrictId] = useState<string | null>(null)
|
||||
const persistentFocusedDistrictRef = useRef<string | null>(null)
|
||||
const rotationAnimationRef = useRef<number | null>(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,26 +238,89 @@ 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
|
||||
|
@ -243,11 +343,11 @@ export default function DistrictLayer({
|
|||
timestamp: new Date(),
|
||||
}
|
||||
|
||||
console.log("Incident clicked:", incidentDetails);
|
||||
console.log("Incident clicked:", incidentDetails)
|
||||
|
||||
const customEvent = new CustomEvent('incident_click', {
|
||||
const customEvent = new CustomEvent("incident_click", {
|
||||
detail: incidentDetails,
|
||||
bubbles: true
|
||||
bubbles: true,
|
||||
})
|
||||
|
||||
if (map.getMap().getCanvas()) {
|
||||
|
@ -255,11 +355,13 @@ export default function DistrictLayer({
|
|||
} else {
|
||||
document.dispatchEvent(customEvent)
|
||||
}
|
||||
}, [map]);
|
||||
},
|
||||
[map],
|
||||
)
|
||||
|
||||
|
||||
const handleClusterClick = useCallback((e: any) => {
|
||||
if (!map) return;
|
||||
const handleClusterClick = useCallback(
|
||||
(e: any) => {
|
||||
if (!map) return
|
||||
|
||||
e.originalEvent.stopPropagation()
|
||||
e.preventDefault()
|
||||
|
@ -269,31 +371,59 @@ export default function DistrictLayer({
|
|||
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) => {
|
||||
; (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],
|
||||
)
|
||||
}, [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,7 +447,21 @@ export default function DistrictLayer({
|
|||
[
|
||||
"match",
|
||||
["get", "kode_kec"],
|
||||
...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
||||
...(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]) => {
|
||||
return [
|
||||
districtId,
|
||||
data.level === "low"
|
||||
|
@ -328,8 +472,8 @@ export default function DistrictLayer({
|
|||
? CRIME_RATE_COLORS.high
|
||||
: CRIME_RATE_COLORS.default,
|
||||
]
|
||||
}),
|
||||
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,7 +693,21 @@ export default function DistrictLayer({
|
|||
[
|
||||
"match",
|
||||
["get", "kode_kec"],
|
||||
...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
||||
...(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]) => {
|
||||
return [
|
||||
districtId,
|
||||
data.level === "low"
|
||||
|
@ -540,8 +718,8 @@ export default function DistrictLayer({
|
|||
? CRIME_RATE_COLORS.high
|
||||
: CRIME_RATE_COLORS.default,
|
||||
]
|
||||
}),
|
||||
CRIME_RATE_COLORS.default,
|
||||
})),
|
||||
focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default,
|
||||
],
|
||||
CRIME_RATE_COLORS.default,
|
||||
] as any)
|
||||
|
@ -550,32 +728,43 @@ 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) {
|
||||
return [
|
||||
districtId,
|
||||
CRIME_RATE_COLORS.default
|
||||
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 [
|
||||
|
@ -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,12 +890,12 @@ 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) => {
|
||||
return filteredIncidents
|
||||
.map((incident) => {
|
||||
if (!incident.locations) {
|
||||
console.warn("Missing location for incident:", incident.id)
|
||||
return null
|
||||
|
@ -643,20 +913,16 @@ export default function DistrictLayer({
|
|||
},
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: [
|
||||
incident.locations.longitude || 0,
|
||||
incident.locations.latitude || 0
|
||||
],
|
||||
coordinates: [incident.locations.longitude || 0, incident.locations.latitude || 0],
|
||||
},
|
||||
}
|
||||
}).filter(Boolean)
|
||||
});
|
||||
|
||||
(map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
|
||||
})
|
||||
.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
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -76,13 +76,13 @@ export default function DistrictPopup({
|
|||
const getCrimeRateBadge = (level?: string) => {
|
||||
switch (level) {
|
||||
case "low":
|
||||
return <Badge className="bg-emerald-600 text-white">Low</Badge>
|
||||
return <Badge className="bg-emerald-600 text-white hover:bg-emerald-600">Low</Badge>
|
||||
case "medium":
|
||||
return <Badge className="bg-amber-500 text-white">Medium</Badge>
|
||||
return <Badge className="bg-amber-500 text-white hover:bg-amber-500">Medium</Badge>
|
||||
case "high":
|
||||
return <Badge className="bg-rose-600 text-white">High</Badge>
|
||||
return <Badge className="bg-rose-600 text-white hover:bg-rose-600">High</Badge>
|
||||
case "critical":
|
||||
return <Badge className="bg-red-700 text-white">Critical</Badge>
|
||||
return <Badge className="bg-red-700 text-white hover:bg-red-700">Critical</Badge>
|
||||
default:
|
||||
return <Badge className="bg-slate-600">Unknown</Badge>
|
||||
}
|
||||
|
|
|
@ -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<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
|
||||
export { Slider }
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue