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:
vergiLgood1 2025-05-03 22:58:14 +07:00
parent fdc0403b81
commit e488bad7c1
9 changed files with 1123 additions and 248 deletions

View File

@ -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>
)
}

View File

@ -20,6 +20,7 @@ import SidebarToggle from "./sidebar/sidebar-toggle"
import { cn } from "@/app/_lib/utils" import { cn } from "@/app/_lib/utils"
import CrimePopup from "./pop-up/crime-popup" import CrimePopup from "./pop-up/crime-popup"
import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client" import { $Enums, crime_categories, crime_incidents, crimes, demographics, districts, geographics, locations } from "@prisma/client"
import { SmoothYearTimeline } from "./controls/year-timeline"
// Updated CrimeIncident type to match the structure in crime_incidents // Updated CrimeIncident type to match the structure in crime_incidents
interface CrimeIncident { interface CrimeIncident {
@ -44,6 +45,7 @@ export default function CrimeMap() {
const [selectedYear, setSelectedYear] = useState<number>(2024) const [selectedYear, setSelectedYear] = useState<number>(2024)
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all") const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
const [activeControl, setActiveControl] = useState<ITopTooltipsMapId>("incidents") const [activeControl, setActiveControl] = useState<ITopTooltipsMapId>("incidents")
const [yearProgress, setYearProgress] = useState(0)
const mapContainerRef = useRef<HTMLDivElement>(null) const mapContainerRef = useRef<HTMLDivElement>(null)
@ -174,6 +176,13 @@ export default function CrimeMap() {
setSelectedDistrict(feature); setSelectedDistrict(feature);
} }
// Handle year-month timeline change
const handleTimelineChange = useCallback((year: number, month: number, progress: number) => {
setSelectedYear(year)
setSelectedMonth(month)
setYearProgress(progress)
}, [])
// Reset filters // Reset filters
const resetFilters = useCallback(() => { const resetFilters = useCallback(() => {
setSelectedYear(2024) setSelectedYear(2024)
@ -295,8 +304,21 @@ export default function CrimeMap() {
/> />
<MapLegend position="bottom-right" /> <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> </MapView>
</div> </div>
</div> </div>

View File

@ -2,11 +2,11 @@
import { useEffect, useState, useRef, useCallback } from "react" import { useEffect, useState, useRef, useCallback } from "react"
import { useMap } from "react-map-gl/mapbox" 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 { $Enums } from "@prisma/client"
import DistrictPopup from "../pop-up/district-popup" 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 // Types for district properties
export interface DistrictFeature { export interface DistrictFeature {
@ -42,6 +42,7 @@ export interface DistrictFeature {
}> }>
selectedYear?: string selectedYear?: string
selectedMonth?: string selectedMonth?: string
isFocused?: boolean // Add a property to track if district is focused
} }
// District layer props // District layer props
@ -74,8 +75,12 @@ export default function DistrictLayer({
const selectedDistrictRef = useRef<DistrictFeature | null>(null) const selectedDistrictRef = useRef<DistrictFeature | null>(null)
const [selectedDistrict, setSelectedDistrict] = useState<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 layersAdded = useRef(false)
const isFocusedMode = useRef(false)
const crimeDataByDistrict = crimes.reduce( const crimeDataByDistrict = crimes.reduce(
(acc, crime) => { (acc, crime) => {
@ -91,16 +96,52 @@ export default function DistrictLayer({
) )
const handleDistrictClick = (e: any) => { 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) { if (incidentFeatures && incidentFeatures.length > 0) {
return; return
} }
if (!map || !e.features || e.features.length === 0) return if (!map || !e.features || e.features.length === 0) return
const feature = e.features[0] const feature = e.features[0]
const districtId = feature.properties.kode_kec 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] || {} const crimeData = crimeDataByDistrict[districtId] || {}
let crime_incidents: Array<{ let crime_incidents: Array<{
@ -115,11 +156,11 @@ export default function DistrictLayer({
longitude: number 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) { if (crimeRecord && crimeRecord.crime_incidents) {
const incidents = crimeRecord.crime_incidents.map(incident => ({ const incidents = crimeRecord.crime_incidents.map((incident) => ({
id: incident.id, id: incident.id,
timestamp: incident.timestamp, timestamp: incident.timestamp,
description: incident.description || "", description: incident.description || "",
@ -128,7 +169,7 @@ export default function DistrictLayer({
type: incident.crime_categories?.type || "", type: incident.crime_categories?.type || "",
address: incident.locations?.address || "", address: incident.locations?.address || "",
latitude: incident.locations?.latitude || 0, latitude: incident.locations?.latitude || 0,
longitude: incident.locations?.longitude || 0 longitude: incident.locations?.longitude || 0,
})) }))
crime_incidents = [...crime_incidents, ...incidents] crime_incidents = [...crime_incidents, ...incidents]
@ -137,32 +178,29 @@ export default function DistrictLayer({
const firstDistrictCrime = districtCrimes.length > 0 ? districtCrimes[0] : null 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( let demographics = firstDistrictCrime?.districts.demographics?.find((d) => d.year === selectedYearNum)
d => d.year === selectedYearNum
);
if (!demographics && firstDistrictCrime?.districts.demographics?.length) { if (!demographics && firstDistrictCrime?.districts.demographics?.length) {
demographics = firstDistrictCrime.districts.demographics demographics = firstDistrictCrime.districts.demographics.sort((a, b) => b.year - a.year)[0]
.sort((a, b) => b.year - a.year)[0]; console.log(
console.log(`Tidak ada data demografis untuk tahun ${selectedYearNum}, menggunakan data tahun ${demographics.year}`); `Tidak ada data demografis untuk tahun ${selectedYearNum}, menggunakan data tahun ${demographics.year}`,
)
} }
let geographics = firstDistrictCrime?.districts.geographics?.find( let geographics = firstDistrictCrime?.districts.geographics?.find((g) => g.year === selectedYearNum)
g => g.year === selectedYearNum
);
if (!geographics && firstDistrictCrime?.districts.geographics?.length) { if (!geographics && firstDistrictCrime?.districts.geographics?.length) {
const validGeographics = firstDistrictCrime.districts.geographics const validGeographics = firstDistrictCrime.districts.geographics
.filter(g => g.year !== null) .filter((g) => g.year !== null)
.sort((a, b) => (b.year || 0) - (a.year || 0)); .sort((a, b) => (b.year || 0) - (a.year || 0))
geographics = validGeographics.length > 0 ? geographics = validGeographics.length > 0 ? validGeographics[0] : firstDistrictCrime.districts.geographics[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 const clickLng = e.lngLat ? e.lngLat.lng : null
@ -181,7 +219,6 @@ export default function DistrictLayer({
const district: DistrictFeature = { const district: DistrictFeature = {
id: districtId, id: districtId,
name: feature.properties.nama || feature.properties.kecamatan || "Unknown District", name: feature.properties.nama || feature.properties.kecamatan || "Unknown District",
// properties: feature.properties,
longitude: geographics.longitude || clickLng || 0, longitude: geographics.longitude || clickLng || 0,
latitude: geographics.latitude || clickLat || 0, latitude: geographics.latitude || clickLat || 0,
number_of_crime: crimeData.number_of_crime || 0, number_of_crime: crimeData.number_of_crime || 0,
@ -201,26 +238,89 @@ export default function DistrictLayer({
}, },
crime_incidents: crime_incidents || [], crime_incidents: crime_incidents || [],
selectedYear: year, selectedYear: year,
selectedMonth: month selectedMonth: month,
isFocused: true, // Mark this district as focused
} }
if (!district.longitude || !district.latitude) { if (!district.longitude || !district.latitude) {
console.error("Invalid district coordinates:", district); console.error("Invalid district coordinates:", district)
return; return
} }
selectedDistrictRef.current = district; selectedDistrictRef.current = district
console.log("District clicked, selectedDistrictRef set to:", selectedDistrictRef.current); 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) { if (onClick) {
onClick(district); onClick(district)
} else { } else {
setSelectedDistrict(district); setSelectedDistrict(district)
} }
} }
const handleIncidentClick = useCallback((e: any) => { const handleIncidentClick = useCallback(
if (!map) return; (e: any) => {
if (!map) return
const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] }) const features = map.queryRenderedFeatures(e.point, { layers: ["unclustered-point"] })
if (!features || features.length === 0) return if (!features || features.length === 0) return
@ -243,11 +343,11 @@ export default function DistrictLayer({
timestamp: new Date(), 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, detail: incidentDetails,
bubbles: true bubbles: true,
}) })
if (map.getMap().getCanvas()) { if (map.getMap().getCanvas()) {
@ -255,11 +355,13 @@ export default function DistrictLayer({
} else { } else {
document.dispatchEvent(customEvent) document.dispatchEvent(customEvent)
} }
}, [map]); },
[map],
)
const handleClusterClick = useCallback(
const handleClusterClick = useCallback((e: any) => { (e: any) => {
if (!map) return; if (!map) return
e.originalEvent.stopPropagation() e.originalEvent.stopPropagation()
e.preventDefault() e.preventDefault()
@ -269,31 +371,59 @@ export default function DistrictLayer({
if (!features || features.length === 0) return if (!features || features.length === 0) return
const clusterId: number = features[0].properties?.cluster_id as number 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 if (err) return
map.easeTo({ map.easeTo({
center: (features[0].geometry as any).coordinates, center: (features[0].geometry as any).coordinates,
zoom: zoom ?? undefined, zoom: zoom ?? undefined,
}) })
})
}, },
[map],
) )
}, [map]);
const handleCloseDistrictPopup = useCallback(() => { const handleCloseDistrictPopup = useCallback(() => {
console.log("Closing district popup"); console.log("Closing district popup")
selectedDistrictRef.current = null; selectedDistrictRef.current = null
setSelectedDistrict(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(() => { useEffect(() => {
if (!map || !visible) return; if (!map || !visible) return
const onStyleLoad = () => { const onStyleLoad = () => {
if (!map) return; if (!map) return
try { try {
if (!map.getMap().getSource("districts")) { if (!map.getMap().getSource("districts")) {
@ -317,7 +447,21 @@ export default function DistrictLayer({
[ [
"match", "match",
["get", "kode_kec"], ["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 [ return [
districtId, districtId,
data.level === "low" data.level === "low"
@ -328,8 +472,8 @@ export default function DistrictLayer({
? CRIME_RATE_COLORS.high ? CRIME_RATE_COLORS.high
: CRIME_RATE_COLORS.default, : CRIME_RATE_COLORS.default,
] ]
}), })),
CRIME_RATE_COLORS.default, focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default,
], ],
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")) { if (crimes.length > 0 && !map.getMap().getSource("crime-incidents")) {
const allIncidents = crimes.flatMap((crime) => { const allIncidents = crimes.flatMap((crime) => {
let filteredIncidents = crime.crime_incidents let filteredIncidents = crime.crime_incidents
if (filterCategory !== "all") { if (filterCategory !== "all") {
filteredIncidents = crime.crime_incidents.filter( 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", source: "crime-incidents",
filter: ["has", "point_count"], filter: ["has", "point_count"],
paint: { paint: {
"circle-color": [ "circle-color": ["step", ["get", "point_count"], "#51bbd6", 5, "#f1f075", 15, "#f28cb1"],
"step", "circle-radius": ["step", ["get", "point_count"], 20, 5, 30, 15, 40],
["get", "point_count"],
"#51bbd6",
5,
"#f1f075",
15,
"#f28cb1",
],
"circle-radius": [
"step",
["get", "point_count"],
20,
5,
30,
15,
40,
],
"circle-opacity": 0.75, "circle-opacity": 0.75,
}, },
layout: { layout: {
"visibility": "visible", visibility: "visible",
} },
}, },
firstSymbolId, firstSymbolId,
) )
@ -473,14 +641,13 @@ export default function DistrictLayer({
"circle-stroke-color": "#fff", "circle-stroke-color": "#fff",
}, },
layout: { layout: {
"visibility": "visible", visibility: "visible",
} },
}, },
firstSymbolId, firstSymbolId,
) )
} }
// Add improved mouse interaction for clusters and points
map.on("mouseenter", "clusters", () => { map.on("mouseenter", "clusters", () => {
map.getCanvas().style.cursor = "pointer" map.getCanvas().style.cursor = "pointer"
}) })
@ -497,7 +664,6 @@ export default function DistrictLayer({
map.getCanvas().style.cursor = "" map.getCanvas().style.cursor = ""
}) })
// Also add hover effect for district fill
map.on("mouseenter", "district-fill", () => { map.on("mouseenter", "district-fill", () => {
map.getCanvas().style.cursor = "pointer" map.getCanvas().style.cursor = "pointer"
}) })
@ -506,20 +672,18 @@ export default function DistrictLayer({
map.getCanvas().style.cursor = "" map.getCanvas().style.cursor = ""
}) })
// Remove old event listeners to avoid duplicates map.off("click", "clusters", handleClusterClick)
map.off("click", "clusters", handleClusterClick); map.off("click", "unclustered-point", handleIncidentClick)
map.off("click", "unclustered-point", handleIncidentClick);
// Add event listeners map.on("click", "clusters", handleClusterClick)
map.on("click", "clusters", handleClusterClick); map.on("click", "unclustered-point", handleIncidentClick)
map.on("click", "unclustered-point", handleIncidentClick);
map.off("click", "district-fill", handleDistrictClick); map.off("click", "district-fill", handleDistrictClick)
map.on("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 { } else {
if (map.getMap().getLayer("district-fill")) { if (map.getMap().getLayer("district-fill")) {
@ -529,7 +693,21 @@ export default function DistrictLayer({
[ [
"match", "match",
["get", "kode_kec"], ["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 [ return [
districtId, districtId,
data.level === "low" data.level === "low"
@ -540,8 +718,8 @@ export default function DistrictLayer({
? CRIME_RATE_COLORS.high ? CRIME_RATE_COLORS.high
: CRIME_RATE_COLORS.default, : CRIME_RATE_COLORS.default,
] ]
}), })),
CRIME_RATE_COLORS.default, focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default,
], ],
CRIME_RATE_COLORS.default, CRIME_RATE_COLORS.default,
] as any) ] as any)
@ -550,32 +728,43 @@ export default function DistrictLayer({
} catch (error) { } catch (error) {
console.error("Error adding district layers:", error) console.error("Error adding district layers:", error)
} }
}; }
if (map.isStyleLoaded()) { if (map.isStyleLoaded()) {
onStyleLoad(); onStyleLoad()
} else { } else {
map.once("style.load", onStyleLoad); map.once("style.load", onStyleLoad)
} }
return () => { return () => {
if (map) { 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(() => { useEffect(() => {
if (!map || !layersAdded.current) return if (!map || !layersAdded.current) return
try { try {
if (map.getMap().getLayer("district-fill")) { if (map.getMap().getLayer("district-fill")) {
const colorEntries = Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => { const colorEntries = focusedDistrictId
if (!data || !data.level) { ? [
return [ [
districtId, focusedDistrictId,
CRIME_RATE_COLORS.default 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 [ return [
@ -597,17 +786,98 @@ export default function DistrictLayer({
"match", "match",
["get", "kode_kec"], ["get", "kode_kec"],
...colorEntries, ...colorEntries,
CRIME_RATE_COLORS.default, focusedDistrictId ? "rgba(0,0,0,0.05)" : CRIME_RATE_COLORS.default,
], ],
CRIME_RATE_COLORS.default, CRIME_RATE_COLORS.default,
] as any ] as any
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression) 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) { } catch (error) {
console.error("Error updating district layer:", error) console.error("Error updating district layer:", error)
} }
}, [map, crimes, crimeDataByDistrict]) }, [map, crimes, crimeDataByDistrict, focusedDistrictId])
useEffect(() => { useEffect(() => {
if (!map || !map.getMap().getSource("crime-incidents")) return if (!map || !map.getMap().getSource("crime-incidents")) return
@ -620,12 +890,12 @@ export default function DistrictLayer({
if (filterCategory !== "all") { if (filterCategory !== "all") {
filteredIncidents = crime.crime_incidents.filter( filteredIncidents = crime.crime_incidents.filter(
incident => incident.crime_categories && (incident) => incident.crime_categories && incident.crime_categories.name === filterCategory,
incident.crime_categories.name === filterCategory
) )
} }
return filteredIncidents.map((incident) => { return filteredIncidents
.map((incident) => {
if (!incident.locations) { if (!incident.locations) {
console.warn("Missing location for incident:", incident.id) console.warn("Missing location for incident:", incident.id)
return null return null
@ -643,20 +913,16 @@ export default function DistrictLayer({
}, },
geometry: { geometry: {
type: "Point" as const, type: "Point" as const,
coordinates: [ coordinates: [incident.locations.longitude || 0, incident.locations.latitude || 0],
incident.locations.longitude || 0,
incident.locations.latitude || 0
],
}, },
} }
}).filter(Boolean) })
}); .filter(Boolean)
})
(map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({ ; (map.getMap().getSource("crime-incidents") as mapboxgl.GeoJSONSource).setData({
type: "FeatureCollection", type: "FeatureCollection",
features: allIncidents as GeoJSON.Feature[], features: allIncidents as GeoJSON.Feature[],
}) })
} catch (error) { } catch (error) {
console.error("Error updating incident data:", error) console.error("Error updating incident data:", error)
} }
@ -664,47 +930,38 @@ export default function DistrictLayer({
useEffect(() => { useEffect(() => {
if (selectedDistrictRef.current) { if (selectedDistrictRef.current) {
const districtId = selectedDistrictRef.current.id; const districtId = selectedDistrictRef.current.id
const crimeData = crimeDataByDistrict[districtId] || {}; const crimeData = crimeDataByDistrict[districtId] || {}
const districtCrime = crimes.find(crime => crime.district_id === districtId); const districtCrime = crimes.find((crime) => crime.district_id === districtId)
if (districtCrime) { 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( let demographics = districtCrime.districts.demographics?.find((d) => d.year === selectedYearNum)
d => d.year === selectedYearNum
);
if (!demographics && districtCrime.districts.demographics?.length) { if (!demographics && districtCrime.districts.demographics?.length) {
demographics = districtCrime.districts.demographics demographics = districtCrime.districts.demographics.sort((a, b) => b.year - a.year)[0]
.sort((a, b) => b.year - a.year)[0];
} }
let geographics = districtCrime.districts.geographics?.find( let geographics = districtCrime.districts.geographics?.find((g) => g.year === selectedYearNum)
g => g.year === selectedYearNum
);
if (!geographics && districtCrime.districts.geographics?.length) { if (!geographics && districtCrime.districts.geographics?.length) {
const validGeographics = districtCrime.districts.geographics const validGeographics = districtCrime.districts.geographics
.filter(g => g.year !== null) .filter((g) => g.year !== null)
.sort((a, b) => (b.year || 0) - (a.year || 0)); .sort((a, b) => (b.year || 0) - (a.year || 0))
geographics = validGeographics.length > 0 ? geographics = validGeographics.length > 0 ? validGeographics[0] : districtCrime.districts.geographics[0]
validGeographics[0] :
districtCrime.districts.geographics[0];
} }
if (!demographics || !geographics) { if (!demographics || !geographics) {
console.error("Missing district data:", { demographics, geographics }); console.error("Missing district data:", { demographics, geographics })
return; return
} }
const crime_incidents = districtCrime.crime_incidents const crime_incidents = districtCrime.crime_incidents
.filter(incident => .filter((incident) => filterCategory === "all" || incident.crime_categories.name === filterCategory)
filterCategory === "all" || incident.crime_categories.name === filterCategory .map((incident) => ({
)
.map(incident => ({
id: incident.id, id: incident.id,
timestamp: incident.timestamp, timestamp: incident.timestamp,
description: incident.description, description: incident.description,
@ -713,8 +970,8 @@ export default function DistrictLayer({
type: incident.crime_categories.type || "", type: incident.crime_categories.type || "",
address: incident.locations.address || "", address: incident.locations.address || "",
latitude: incident.locations.latitude, latitude: incident.locations.latitude,
longitude: incident.locations.longitude longitude: incident.locations.longitude,
})); }))
const updatedDistrict: DistrictFeature = { const updatedDistrict: DistrictFeature = {
...selectedDistrictRef.current, ...selectedDistrictRef.current,
@ -735,22 +992,117 @@ export default function DistrictLayer({
}, },
crime_incidents, crime_incidents,
selectedYear: year, selectedYear: year,
selectedMonth: month selectedMonth: month,
}; isFocused: true, // Ensure this stays true
}
selectedDistrictRef.current = updatedDistrict; selectedDistrictRef.current = updatedDistrict
setSelectedDistrict(prevDistrict => { setSelectedDistrict((prevDistrict) => {
if (prevDistrict?.id === updatedDistrict.id && if (
prevDistrict?.id === updatedDistrict.id &&
prevDistrict?.selectedYear === updatedDistrict.selectedYear && prevDistrict?.selectedYear === updatedDistrict.selectedYear &&
prevDistrict?.selectedMonth === updatedDistrict.selectedMonth) { prevDistrict?.selectedMonth === updatedDistrict.selectedMonth
return prevDistrict; ) {
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 if (!visible) return null

View File

@ -4,7 +4,7 @@ import type React from "react"
import { useState, useCallback, useRef } from "react" import { useState, useCallback, useRef } from "react"
import { type ViewState, Map, type MapRef, NavigationControl, GeolocateControl } from "react-map-gl/mapbox" import { type ViewState, Map, type MapRef, NavigationControl, GeolocateControl } from "react-map-gl/mapbox"
import { FullscreenControl } 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 "mapbox-gl/dist/mapbox-gl.css"
import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder'; import MapboxGeocoder from '@mapbox/mapbox-gl-geocoder';
import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'; import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
@ -39,8 +39,8 @@ export default function MapView({
longitude: BASE_LONGITUDE, longitude: BASE_LONGITUDE,
latitude: BASE_LATITUDE, latitude: BASE_LATITUDE,
zoom: BASE_ZOOM, zoom: BASE_ZOOM,
bearing: 0, bearing: BASE_BEARING,
pitch: 0, pitch: BASE_PITCH,
...initialViewState, ...initialViewState,
} }

View File

@ -76,13 +76,13 @@ export default function DistrictPopup({
const getCrimeRateBadge = (level?: string) => { const getCrimeRateBadge = (level?: string) => {
switch (level) { switch (level) {
case "low": 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": 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": 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": 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: default:
return <Badge className="bg-slate-600">Unknown</Badge> return <Badge className="bg-slate-600">Unknown</Badge>
} }

View File

@ -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 }

View File

@ -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_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_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 BASE_LONGITUDE = 113.65; // Default longitude for the map center (Jember region)
export const MAPBOX_ACCESS_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN; export const MAPBOX_ACCESS_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN;

View File

@ -23,6 +23,7 @@
"@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^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": { "node_modules/@radix-ui/react-slot": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", "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": { "node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",

View File

@ -29,6 +29,7 @@
"@radix-ui/react-scroll-area": "^1.2.3", "@radix-ui/react-scroll-area": "^1.2.3",
"@radix-ui/react-select": "^2.1.6", "@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-switch": "^1.1.3", "@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",