diff --git a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/page.tsx b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/page.tsx index 806a73b..a9a19cd 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/page.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/crime-management/crime-overview/page.tsx @@ -18,13 +18,11 @@ import CaseSearch from "./_components/case-search" const bentoGridItems: BentoGridItemProps[] = [ { - title: "Incident Map", - description: "Recent crime locations in the district", - icon: , colSpan: "2", rowSpan: "2", // suffixMenu: { }} />, component: , + className: "p-0" }, { title: "Crime Statistics", diff --git a/sigap-website/app/_components/map/controls/layer-control.tsx b/sigap-website/app/_components/map/controls/layer-control.tsx new file mode 100644 index 0000000..30cf685 --- /dev/null +++ b/sigap-website/app/_components/map/controls/layer-control.tsx @@ -0,0 +1,126 @@ +import { Checkbox } from "../../ui/checkbox"; +import { Label } from "../../ui/label"; +import { RadioGroup, RadioGroupItem } from "../../ui/radio-group"; +import { ControlPosition, IControl, Map } from "mapbox-gl" +import { useControl } from "react-map-gl/mapbox" +import React, { useEffect } from "react" +import { createRoot } from "react-dom/client" + +interface MapLayerControlProps { + position?: ControlPosition + isFullscreen?: boolean +} + +// React component for layer control content +const LayerControlContent = () => { + return ( +
+
Lapisan peta
+ +
+ + +
+ +
+ + +
+ +
+
Waktu
+ +
+ + +
+
+ + +
+
+
+
+ ) +} + +class LayerControlMapboxControl implements IControl { + private container: HTMLElement; + private map?: Map; + private props: MapLayerControlProps; + private root: ReturnType | null = null; + private isUnmounting: boolean = false; + + constructor(props: MapLayerControlProps) { + this.props = props; + this.container = document.createElement("div"); + this.container.className = "mapboxgl-ctrl"; + } + + onAdd(map: Map): HTMLElement { + this.map = map; + this.container.className = 'mapboxgl-ctrl mapboxgl-ctrl-layer absolute bottom-36 left-0 z-20 bg-black/70 text-white p-3 rounded-tr-lg'; + this.render(); + return this.container; + } + + onRemove(): void { + if (this.isUnmounting) return; + + this.isUnmounting = true; + requestAnimationFrame(() => { + if (this.root) { + this.root.unmount(); + this.root = null; + } + if (this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + this.map = undefined; + this.isUnmounting = false; + }); + } + + updateProps(props: MapLayerControlProps): void { + this.props = props; + this.render(); + } + + render(): void { + if (this.props.isFullscreen === false) { + if (this.container.style.display !== 'none') { + this.container.style.display = 'none'; + } + return; + } else { + this.container.style.display = 'block'; + } + + if (!this.root) { + this.root = createRoot(this.container); + } + + this.root.render(); + } +} + +export function MapLayerControl({ position = 'bottom-left', isFullscreen }: MapLayerControlProps) { + const control = useControl( + () => new LayerControlMapboxControl({ position, isFullscreen }), + { position } + ); + + useEffect(() => { + control.updateProps({ position, isFullscreen }); + }, [control, position, isFullscreen]); + + return null; +} diff --git a/sigap-website/app/_components/map/controls/map-filter-control.tsx b/sigap-website/app/_components/map/controls/map-filter-control.tsx index 91d923e..4ffcc3c 100644 --- a/sigap-website/app/_components/map/controls/map-filter-control.tsx +++ b/sigap-website/app/_components/map/controls/map-filter-control.tsx @@ -3,7 +3,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select" import { Button } from "@/app/_components/ui/button" import { FilterX } from "lucide-react" -import { getMonthName } from "@/app/_utils/common" import { useCallback } from "react" interface MapFilterControlProps { @@ -53,7 +52,7 @@ export default function MapFilterControl({ const isDefaultFilter = selectedYear === 2024 && selectedMonth === "all" return ( -
+
Map Filters
diff --git a/sigap-website/app/_components/map/controls/map-legend.tsx b/sigap-website/app/_components/map/controls/map-legend.tsx index 291b9d4..5b2da6a 100644 --- a/sigap-website/app/_components/map/controls/map-legend.tsx +++ b/sigap-website/app/_components/map/controls/map-legend.tsx @@ -1,58 +1,109 @@ -import { AlertCircle, AlertTriangle, PackageX, ShieldAlert, Siren } from "lucide-react" import { CRIME_RATE_COLORS } from "@/app/_utils/const/map" +import { ControlPosition, IControl, Map } from "mapbox-gl" +import { useControl } from "react-map-gl/mapbox" +import React, { useEffect } from "react" +import { createRoot } from "react-dom/client" -export function MapLegend() { +interface MapLegendProps { + position?: ControlPosition + isFullscreen?: boolean +} + +// React component for legend content +const LegendContent = () => { return ( -
-
Crime Rates
-
-
- - Low -
-
- - Medium -
-
- - High -
-
- - Critical -
+
+
+ Low
- -
Incident Types
-
-
- - General -
-
- - Theft -
-
- - Accident -
-
- - Violence -
-
- - Juvenile -
+
+ Medium +
+
+ High
) } + +class MapLegendControl implements IControl { + private container: HTMLElement; + private map?: Map; + private props: MapLegendProps; + private root: ReturnType | null = null; + private isUnmounting: boolean = false; + + constructor(props: MapLegendProps) { + this.props = props; + this.container = document.createElement("div"); + this.container.className = "mapboxgl-ctrl"; + } + + onAdd(map: Map): HTMLElement { + this.map = map; + this.container.className = 'mapboxgl-ctrl mapboxgl-ctrl-legend'; + this.render(); + return this.container; + } + + onRemove(): void { + // Prevent multiple unmounting attempts + if (this.isUnmounting) return; + + this.isUnmounting = true; + + // Schedule unmounting to happen after current render cycle completes + requestAnimationFrame(() => { + if (this.root) { + this.root.unmount(); + this.root = null; + } + + if (this.container.parentNode) { + this.container.parentNode.removeChild(this.container); + } + + this.map = undefined; + this.isUnmounting = false; + }); + } + + updateProps(props: MapLegendProps): void { + this.props = props; + this.render(); + } + + render(): void { + // Only render if in fullscreen or if isFullscreen prop is not provided + if (this.props.isFullscreen === false) { + if (this.container.style.display !== 'none') { + this.container.style.display = 'none'; + } + return; + } else { + this.container.style.display = 'block'; + } + + // Create a root if it doesn't exist + if (!this.root) { + this.root = createRoot(this.container); + } + + // Render using the createRoot API + this.root.render(); + } +} + +export function MapLegend({ position = 'bottom-right', isFullscreen }: MapLegendProps) { + const control = useControl( + () => new MapLegendControl({ position, isFullscreen }), + { position } + ); + + // Update control when props change + useEffect(() => { + control.updateProps({ position, isFullscreen }); + }, [control, position, isFullscreen]); + + return null; +} \ No newline at end of file diff --git a/sigap-website/app/_components/map/controls/time-slider.tsx b/sigap-website/app/_components/map/controls/time-slider.tsx new file mode 100644 index 0000000..9aacd4a --- /dev/null +++ b/sigap-website/app/_components/map/controls/time-slider.tsx @@ -0,0 +1,69 @@ +"use client" + + +import { Play } from "lucide-react" +import { Button } from "../../ui/button" + +interface TimeSliderProps { + selectedTime: string + onTimeChange: (time: string) => void +} + +export function TimeSlider({ selectedTime, onTimeChange }: TimeSliderProps) { + const times = ["19:00", "19:30", "20:00", "20:30", "21:00", "21:30", "22:00", "22:30", "23:00", "23:30", "00:00"] + + const currentDate = new Date() + const formattedDate = `${currentDate.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + })}, ${currentDate.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + })} GMT+7` + + return ( +
+
+ +
{formattedDate}
+
+ +
+ {/* Current time indicator */} +
+ + {/* Time slots */} +
+ {times.map((time, index) => ( +
onTimeChange(time)} + > + {time} +
+ ))} +
+ + {/* Legend at the bottom */} +
+
+
Tinggi
+
+
0-30 mnt
+
30-60 mnt
+
60-90 mnt
+
90-120 mnt
+
+
+
+
+
+ ) +} diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index 00ab63d..135fe81 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -14,7 +14,6 @@ import { useState } from "react" import { CrimePopup } from "./pop-up" import CrimeMarker, { type CrimeIncident } from "./markers/crime-marker" import { MapLegend } from "./controls/map-legend" -import MapFilterControl from "./controls/map-filter-control" const months = [ { value: "1", label: "January" }, @@ -89,23 +88,9 @@ export default function CrimeMap() { return title } - // Create map filter controls - now MapView will only render these in fullscreen mode - const mapFilterControls = ( - - ) - return ( - - + + Crime Map {getMapTitle()}
{/* Regular (non-fullscreen) controls */} @@ -146,12 +131,12 @@ export default function CrimeMap() { Apply - + */}
@@ -173,10 +158,15 @@ export default function CrimeMap() { crimes={crimes} selectedYear={selectedYear} selectedMonth={selectedMonth} - customControls={mapFilterControls} + availableYears={availableYears} + yearsLoading={yearsLoading} + onYearChange={setSelectedYear} + onMonthChange={setSelectedMonth} + onApplyFilters={applyFilters} + onResetFilters={resetFilters} > {/* Show the legend regardless of fullscreen state if showLegend is true */} - {showLegend && } + {} {/* District Layer with crime data */} - {/* Display all crime incident markers */} - {/* {allIncidents?.map((incident) => ( - - ))} */} - {/* Popup for selected incident */} {selectedIncident && ( 0 && !map.getMap().getSource("crime-incidents")) { @@ -441,8 +439,6 @@ export default function DistrictLayer({ ? CRIME_RATE_COLORS.medium : data.level === "high" ? CRIME_RATE_COLORS.high - : data.level === "critical" - ? CRIME_RATE_COLORS.critical : CRIME_RATE_COLORS.default, ] }), @@ -506,8 +502,6 @@ export default function DistrictLayer({ ? CRIME_RATE_COLORS.medium : data.level === "high" ? CRIME_RATE_COLORS.high - : data.level === "critical" - ? CRIME_RATE_COLORS.critical : CRIME_RATE_COLORS.default, ] }), diff --git a/sigap-website/app/_components/map/map.tsx b/sigap-website/app/_components/map/map.tsx index e84aeea..eaf28ff 100644 --- a/sigap-website/app/_components/map/map.tsx +++ b/sigap-website/app/_components/map/map.tsx @@ -2,7 +2,7 @@ import type React from "react" -import { useState, useCallback, useEffect, useRef } from "react" +import { useState, useCallback, useRef } from "react" import ReactMapGL, { type ViewState, NavigationControl, @@ -11,21 +11,21 @@ import ReactMapGL, { FullscreenControl, GeolocateControl, } from "react-map-gl/mapbox" -import { BASE_LATITUDE, BASE_LONGITUDE, BASE_ZOOM, MAP_STYLE } from "@/app/_utils/const/map" +import { BASE_LATITUDE, BASE_LONGITUDE, BASE_ZOOM, MAP_STYLE, MAPBOX_STYLES, MapboxStyle } from "@/app/_utils/const/map" import { Search } from "lucide-react" import "mapbox-gl/dist/mapbox-gl.css" import MapSidebar from "./controls/map-sidebar" import SidebarToggle from "./controls/map-toggle" -import MapControls from "./controls/map-control" import TimeControls from "./controls/time-control" import SeverityIndicator from "./controls/severity-indicator" -import { useFullscreen } from "@/app/_hooks/use-fullscreen" import MapFilterControl from "./controls/map-filter-control" +import { useFullscreen } from "@/app/_hooks/use-fullscreen" +import MapControls from "./controls/map-control" interface MapViewProps { children?: React.ReactNode initialViewState?: Partial - mapStyle?: string + mapStyle?: MapboxStyle className?: string width?: string | number height?: string | number @@ -42,12 +42,18 @@ interface MapViewProps { }> selectedYear?: number | string selectedMonth?: number | string + availableYears?: (number | null)[] + yearsLoading?: boolean + onYearChange?: (year: number) => void + onMonthChange?: (month: number | "all") => void + onApplyFilters?: () => void + onResetFilters?: () => void } export default function MapView({ children, initialViewState, - mapStyle = MAP_STYLE, + mapStyle = MAPBOX_STYLES.Standard, className = "w-full h-96", width = "100%", height = "100%", @@ -57,12 +63,20 @@ export default function MapView({ crimes = [], selectedYear, selectedMonth, + availableYears = [], + yearsLoading = false, + onYearChange = () => { }, + onMonthChange = () => { }, + onApplyFilters = () => { }, + onResetFilters = () => { }, }: MapViewProps) { const [mapRef, setMapRef] = useState(null) const [activeControl, setActiveControl] = useState("crime-rate") const [activeTime, setActiveTime] = useState("today") const [sidebarOpen, setSidebarOpen] = useState(false) const mapContainerRef = useRef(null) + + // Use the custom fullscreen hook instead of manual event listeners const { isFullscreen } = useFullscreen(mapContainerRef) const defaultViewState: Partial = { @@ -80,21 +94,19 @@ export default function MapView({ const handleMoveEnd = useCallback( (event: any) => { - if (onMoveEnd) { - onMoveEnd(event.viewState) - } + if (onMoveEnd) { + onMoveEnd(event.viewState) + } }, [onMoveEnd], ) const handleControlChange = (control: string) => { setActiveControl(control) - // Here you would implement logic to change the map display based on the selected control } const handleTimeChange = (time: string) => { setActiveTime(time) - // Here you would implement logic to change the time period of data shown } const toggleSidebar = () => { @@ -103,40 +115,6 @@ export default function MapView({ return (
- {/* Custom controls - only show when in fullscreen mode */} - {isFullscreen && ( - <> - {/* Sidebar */} - - - - {/* Additional controls that should only appear in fullscreen */} -
- - -
- - - - - - {/* Make sure customControls is displayed in fullscreen mode */} - {customControls} - - )} - {/* Main content with left padding when sidebar is open */}
{children} - - - + + - {/* GeolocateControl only shown in fullscreen mode */} + {/* Custom controls that depend on fullscreen state */} + {isFullscreen && } + + {/* Custom Filter Control with isFullscreen prop */} + {/* {isFullscreen && + } */} + {/* Sidebar and other controls only in fullscreen */} {isFullscreen && ( - + <> + + + + + + )}
- {/* Debug indicator - remove in production */} + {/* Debug indicator - uncomment for debugging
Fullscreen: {isFullscreen ? "Yes" : "No"} -
+
*/}
) } diff --git a/sigap-website/app/_hooks/use-fullscreen.ts b/sigap-website/app/_hooks/use-fullscreen.ts index 3be7033..7375550 100644 --- a/sigap-website/app/_hooks/use-fullscreen.ts +++ b/sigap-website/app/_hooks/use-fullscreen.ts @@ -2,14 +2,15 @@ import { useState, useEffect, RefObject } from 'react'; -export function useFullscreen(ref: RefObject) { +/** + * Hook for detecting fullscreen state changes + * @param containerRef Reference to the container element + * @returns Object containing the fullscreen state and functions to control it + */ +export function useFullscreen(containerRef: RefObject) { const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { - if (!ref.current) return; - - const element = ref.current; - const handleFullscreenChange = () => { const fullscreenElement = document.fullscreenElement || @@ -17,19 +18,16 @@ export function useFullscreen(ref: RefObject) { (document as any).mozFullScreenElement || (document as any).msFullscreenElement; - setIsFullscreen( - fullscreenElement === element || - (fullscreenElement && element.contains(fullscreenElement)) - ); + setIsFullscreen(!!fullscreenElement); }; - // Add event listeners for fullscreen changes + // Add event listeners for fullscreen changes across browsers document.addEventListener('fullscreenchange', handleFullscreenChange); document.addEventListener('webkitfullscreenchange', handleFullscreenChange); document.addEventListener('mozfullscreenchange', handleFullscreenChange); document.addEventListener('MSFullscreenChange', handleFullscreenChange); - // Clean up event listeners + // Cleanup function to remove event listeners return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange); document.removeEventListener( @@ -45,13 +43,15 @@ export function useFullscreen(ref: RefObject) { handleFullscreenChange ); }; - }, [ref]); + }, []); - // Function to request fullscreen + /** + * Requests fullscreen for the container element + */ const enterFullscreen = () => { - if (!ref.current) return; + if (!containerRef.current) return; - const element = ref.current; + const element = containerRef.current; if (element.requestFullscreen) { element.requestFullscreen(); @@ -64,7 +64,9 @@ export function useFullscreen(ref: RefObject) { } }; - // Function to exit fullscreen + /** + * Exits fullscreen mode + */ const exitFullscreen = () => { if (document.exitFullscreen) { document.exitFullscreen(); @@ -77,6 +79,9 @@ export function useFullscreen(ref: RefObject) { } }; + /** + * Toggles fullscreen mode + */ const toggleFullscreen = () => { if (isFullscreen) { exitFullscreen(); @@ -85,5 +90,14 @@ export function useFullscreen(ref: RefObject) { } }; - return { isFullscreen, enterFullscreen, exitFullscreen, toggleFullscreen }; + // Cek apakah hook use-fullscreen mengembalikan nilai isFullscreen dengan benar. + // Tambahkan console.log untuk debugging jika diperlukan. + console.log("Fullscreen state:", isFullscreen); + + return { + isFullscreen, + enterFullscreen, + exitFullscreen, + toggleFullscreen, + }; } diff --git a/sigap-website/app/_utils/common.ts b/sigap-website/app/_utils/common.ts index 56bca44..87123ba 100644 --- a/sigap-website/app/_utils/common.ts +++ b/sigap-website/app/_utils/common.ts @@ -770,3 +770,31 @@ export const getDistrictName = (districtId: string): string => { 'Unknown District' ); }; + + + +// This is a simplified version of +// https://github.com/facebook/react/blob/4131af3e4bf52f3a003537ec95a1655147c81270/src/renderers/dom/shared/CSSPropertyOperations.js#L62 +const unitlessNumber = + /box|flex|grid|column|lineHeight|fontWeight|opacity|order|tabSize|zIndex/; + +export function CApplyReactStyle( + element: HTMLElement, + styles: React.CSSProperties +) { + if (!element || !styles) { + return; + } + const style = element.style; + + for (const key in styles) { + const value = styles[key as keyof React.CSSProperties]; + if (value !== undefined) { + if (Number.isFinite(value) && !unitlessNumber.test(key)) { + style[key as any] = `${value}px`; + } else { + style[key as any] = value as string; + } + } + } +} diff --git a/sigap-website/app/_utils/const/map.ts b/sigap-website/app/_utils/const/map.ts index 24914d2..0b536e3 100644 --- a/sigap-website/app/_utils/const/map.ts +++ b/sigap-website/app/_utils/const/map.ts @@ -9,8 +9,8 @@ export const MAPBOX_TILESET_ID = process.env.NEXT_PUBLIC_MAPBOX_TILESET_ID; // R export const CRIME_RATE_COLORS = { low: '#4ade80', // green medium: '#facc15', // yellow - high: '#f97316', // orange - critical: '#ef4444', // red + high: '#ef4444', // red + // critical: '#ef4444', // red default: '#94a3b8', // gray };