diff --git a/sigap-website/app/_components/map/controls/map-control.tsx b/sigap-website/app/_components/map/controls/map-control.tsx index d27b377..45e7147 100644 --- a/sigap-website/app/_components/map/controls/map-control.tsx +++ b/sigap-website/app/_components/map/controls/map-control.tsx @@ -14,13 +14,16 @@ import { Users, Siren, } from "lucide-react" +import { Overlay } from "../overlay" +import { ControlPosition } from "mapbox-gl" interface MapControlsProps { onControlChange: (control: string) => void activeControl: string + position?: ControlPosition } -export default function MapControls({ onControlChange, activeControl }: MapControlsProps) { +export default function MapControls({ onControlChange, activeControl, position = "top-left" }: MapControlsProps) { const controls = [ { id: "crime-rate", icon: , label: "Crime Rate" }, { id: "theft", icon: , label: "Theft" }, @@ -36,6 +39,7 @@ export default function MapControls({ onControlChange, activeControl }: MapContr ] return ( +
{controls.map((control) => ( @@ -61,5 +65,6 @@ export default function MapControls({ onControlChange, activeControl }: MapContr ))}
+
) } 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 4ffcc3c..b52904e 100644 --- a/sigap-website/app/_components/map/controls/map-filter-control.tsx +++ b/sigap-website/app/_components/map/controls/map-filter-control.tsx @@ -39,15 +39,21 @@ export default function MapFilterControl({ onYearChange, onMonthChange, onApplyFilters, - onResetFilters + onResetFilters, }: MapFilterControlProps) { - const handleYearChange = useCallback((value: string) => { - onYearChange(Number(value)) - }, [onYearChange]) + const handleYearChange = useCallback( + (value: string) => { + onYearChange(Number(value)) + }, + [onYearChange], + ) - const handleMonthChange = useCallback((value: string) => { - onMonthChange(value === "all" ? "all" : Number(value)) - }, [onMonthChange]) + const handleMonthChange = useCallback( + (value: string) => { + onMonthChange(value === "all" ? "all" : Number(value)) + }, + [onMonthChange], + ) const isDefaultFilter = selectedYear === 2024 && selectedMonth === "all" @@ -72,10 +78,7 @@ export default function MapFilterControl({ - @@ -93,12 +96,7 @@ export default function MapFilterControl({ - diff --git a/sigap-website/app/_components/map/controls/map-legend.tsx b/sigap-website/app/_components/map/controls/map-legend.tsx index 5b2da6a..5f1313f 100644 --- a/sigap-website/app/_components/map/controls/map-legend.tsx +++ b/sigap-website/app/_components/map/controls/map-legend.tsx @@ -1,109 +1,25 @@ -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" +import { CRIME_RATE_COLORS } from "@/app/_utils/const/map"; +import { Overlay } from "../overlay"; +import { ControlPosition } from "mapbox-gl"; interface MapLegendProps { position?: ControlPosition - isFullscreen?: boolean } -// React component for legend content -const LegendContent = () => { +export default function MapLegend({ position = "bottom-right" }: MapLegendProps) { return ( -
-
- Low + +
+
+ Low +
+
+ Medium +
+
+ High +
-
- 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/map-toggle.tsx b/sigap-website/app/_components/map/controls/map-toggle.tsx index 145e63a..f9a7f8c 100644 --- a/sigap-website/app/_components/map/controls/map-toggle.tsx +++ b/sigap-website/app/_components/map/controls/map-toggle.tsx @@ -2,16 +2,20 @@ import { Button } from "@/app/_components/ui/button" import { Menu } from "lucide-react" +import { Overlay } from "../overlay" +import { ControlPosition } from "mapbox-gl" interface SidebarToggleProps { isOpen: boolean onToggle: () => void + position?: ControlPosition } -export default function SidebarToggle({ isOpen, onToggle }: SidebarToggleProps) { +export default function SidebarToggle({ isOpen, onToggle, position = "left" }: SidebarToggleProps) { if (isOpen) return null return ( + + ) } diff --git a/sigap-website/app/_components/map/controls/time-control.tsx b/sigap-website/app/_components/map/controls/time-control.tsx index f36052a..5dd0d11 100644 --- a/sigap-website/app/_components/map/controls/time-control.tsx +++ b/sigap-website/app/_components/map/controls/time-control.tsx @@ -1,13 +1,17 @@ "use client" import { Checkbox } from "@/app/_components/ui/checkbox" import { Label } from "@/app/_components/ui/label" +import { Overlay } from "../overlay" +import { ControlPosition } from "mapbox-gl" + interface TimeControlsProps { onTimeChange: (time: string) => void activeTime: string + position?: ControlPosition } -export default function TimeControls({ onTimeChange, activeTime }: TimeControlsProps) { +export default function TimeControls({ onTimeChange, activeTime, position = "bottom-left" }: TimeControlsProps) { const times = [ { id: "today", label: "Hari ini" }, { id: "yesterday", label: "Kemarin" }, @@ -16,16 +20,18 @@ export default function TimeControls({ onTimeChange, activeTime }: TimeControlsP ] return ( -
-
Waktu
- {times.map((time) => ( -
- onTimeChange(time.id)} /> - -
- ))} -
+ +
+
Waktu
+ {times.map((time) => ( +
+ onTimeChange(time.id)} /> + +
+ ))} +
+
) } diff --git a/sigap-website/app/_components/map/crime-map.tsx b/sigap-website/app/_components/map/crime-map.tsx index 135fe81..bc8c935 100644 --- a/sigap-website/app/_components/map/crime-map.tsx +++ b/sigap-website/app/_components/map/crime-map.tsx @@ -13,7 +13,7 @@ import { useState } from "react" import { CrimePopup } from "./pop-up" import CrimeMarker, { type CrimeIncident } from "./markers/crime-marker" -import { MapLegend } from "./controls/map-legend" + const months = [ { value: "1", label: "January" }, @@ -165,8 +165,6 @@ export default function CrimeMap() { onApplyFilters={applyFilters} onResetFilters={resetFilters} > - {/* Show the legend regardless of fullscreen state if showLegend is true */} - {} {/* District Layer with crime data */} {/* Main content with left padding when sidebar is open */}
- setMapRef(ref)} mapStyle={mapStyle} mapboxAccessToken={mapboxApiAccessToken} @@ -132,26 +134,20 @@ export default function MapView({ - {/* Custom controls that depend on fullscreen state */} - {isFullscreen && } - - {/* Custom Filter Control with isFullscreen prop */} - {/* {isFullscreen && - } */} {/* Sidebar and other controls only in fullscreen */} {isFullscreen && ( <> + {/* */} + - - - - + + {/* Map Legend - positioned at bottom-right */} + + + {/* Sidebar toggle - positioned on the left */} + + + {/* Map Control - positioned at top-left */} + + + {/* Time/year selector - positioned at bottom-left */} + + )} - +
- - {/* Debug indicator - uncomment for debugging -
- Fullscreen: {isFullscreen ? "Yes" : "No"} -
*/}
) } diff --git a/sigap-website/app/_components/map/overlay.tsx b/sigap-website/app/_components/map/overlay.tsx new file mode 100644 index 0000000..44d880d --- /dev/null +++ b/sigap-website/app/_components/map/overlay.tsx @@ -0,0 +1,96 @@ +import { IControl, Map } from "mapbox-gl"; +import { ControlPosition } from "mapbox-gl"; +import { cloneElement, memo, ReactElement, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { useControl } from "react-map-gl/mapbox"; +import { v4 as uuidv4 } from 'uuid'; + +type OverlayProps = { + position: ControlPosition; + children: ReactElement<{ map?: Map }>; + id?: string; +}; + +// Definisikan custom control untuk overlay +class OverlayControl implements IControl { + _map: Map | null = null; + _container: HTMLElement | null = null; + _position: ControlPosition; + _id: string; + _redraw?: () => void; + + constructor({ position, id, redraw }: { position: ControlPosition; id: string; redraw?: () => void }) { + this._position = position; + this._id = id; + this._redraw = redraw; + } + + onAdd(map: Map) { + this._map = map; + this._container = document.createElement('div'); + this._container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group'; + this._container.id = this._id; + + if (this._redraw) { + map.on('move', this._redraw); + this._redraw(); + } + + return this._container; + } + + onRemove() { + if (!this._map || !this._container) return; + + if (this._redraw) { + this._map.off('move', this._redraw); + } + + this._container.remove(); + this._map = null; + } + + getDefaultPosition() { + return this._position; + } + + getMap() { + return this._map; + } + + getElement() { + return this._container; + } +} + +// Komponen Overlay yang telah ditingkatkan +function _Overlay({ position, children, id = `overlay-${uuidv4()}` }: OverlayProps) { + const [container, setContainer] = useState(null); + const [map, setMap] = useState(null) + + // Gunakan useControl dengan ID unik untuk menghindari konflik + const ctrl = useControl( + () => new OverlayControl({ position, id }), + { position } // Hanya menggunakan position yang valid dalam ControlOptions + ); + + // Update container dan map instance ketika control siap + useEffect(() => { + if (ctrl) { + setContainer(ctrl.getElement()); + setMap(ctrl.getMap()); + } + }, [ctrl]); + + // Hanya render jika container sudah siap + if (!container || !map) return null; + + // Gunakan createPortal untuk merender children ke container + return createPortal( + cloneElement(children, { map }), + container + ); +} + +// Export sebagai komponen memoized +export const Overlay = memo(_Overlay); \ No newline at end of file diff --git a/sigap-website/app/_hooks/use-fullscreen.ts b/sigap-website/app/_hooks/use-fullscreen.ts index 7375550..9bea48d 100644 --- a/sigap-website/app/_hooks/use-fullscreen.ts +++ b/sigap-website/app/_hooks/use-fullscreen.ts @@ -90,10 +90,6 @@ export function useFullscreen(containerRef: RefObject) { } }; - // 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, diff --git a/sigap-website/package-lock.json b/sigap-website/package-lock.json index 9cef05e..2a9e88c 100644 --- a/sigap-website/package-lock.json +++ b/sigap-website/package-lock.json @@ -7,6 +7,7 @@ "dependencies": { "@evyweb/ioctopus": "^1.2.0", "@hookform/resolvers": "^4.1.2", + "@mapbox/mapbox-gl-draw": "^1.5.0", "@prisma/client": "^6.4.1", "@prisma/instrumentation": "^6.5.0", "@radix-ui/react-alert-dialog": "^1.1.6", @@ -1644,6 +1645,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/geojson-area": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz", + "integrity": "sha512-bBqqFn1kIbLBfn7Yq1PzzwVkPYQr9lVUeT8Dhd0NL5n76PBuXzOcuLV7GOSbEB1ia8qWxH4COCvFpziEu/yReA==", + "license": "BSD-2-Clause", + "dependencies": { + "wgs84": "0.0.0" + } + }, + "node_modules/@mapbox/geojson-normalize": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-normalize/-/geojson-normalize-0.0.1.tgz", + "integrity": "sha512-82V7YHcle8lhgIGqEWwtXYN5cy0QM/OHq3ypGhQTbvHR57DF0vMHMjjVSQKFfVXBe/yWCBZTyOuzvK7DFFnx5Q==", + "license": "ISC", + "bin": { + "geojson-normalize": "geojson-normalize" + } + }, "node_modules/@mapbox/jsonlint-lines-primitives": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", @@ -1652,6 +1671,52 @@ "node": ">= 0.6" } }, + "node_modules/@mapbox/mapbox-gl-draw": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-draw/-/mapbox-gl-draw-1.5.0.tgz", + "integrity": "sha512-uchQbTa8wiv6GWWTbxW1g5b8H6VySz4t91SmduNH6jjWinPze7cjcmsPUEzhySXsYpYr2/50gRJLZz3bx7O88A==", + "license": "ISC", + "dependencies": { + "@mapbox/geojson-area": "^0.2.2", + "@mapbox/geojson-normalize": "^0.0.1", + "@mapbox/point-geometry": "^1.1.0", + "fast-deep-equal": "^3.1.3", + "nanoid": "^5.0.9" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/@mapbox/mapbox-gl-draw/node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/mapbox-gl-draw/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/@mapbox/mapbox-gl-draw/node_modules/nanoid": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.5.tgz", + "integrity": "sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/@mapbox/mapbox-gl-supported": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-supported/-/mapbox-gl-supported-3.0.0.tgz", @@ -14015,6 +14080,12 @@ "node": ">=4.0" } }, + "node_modules/wgs84": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/wgs84/-/wgs84-0.0.0.tgz", + "integrity": "sha512-ANHlY4Rb5kHw40D0NJ6moaVfOCMrp9Gpd1R/AIQYg2ko4/jzcJ+TVXYYF6kXJqQwITvEZP4yEthjM7U6rYlljQ==", + "license": "BSD-2-Clause" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/sigap-website/package.json b/sigap-website/package.json index f4c8c89..b988325 100644 --- a/sigap-website/package.json +++ b/sigap-website/package.json @@ -13,6 +13,7 @@ "dependencies": { "@evyweb/ioctopus": "^1.2.0", "@hookform/resolvers": "^4.1.2", + "@mapbox/mapbox-gl-draw": "^1.5.0", "@prisma/client": "^6.4.1", "@prisma/instrumentation": "^6.5.0", "@radix-ui/react-alert-dialog": "^1.1.6",