feat: implement map layer control and time slider components for enhanced crime data visualization

This commit is contained in:
vergiLgood1 2025-04-28 23:45:06 +07:00
parent 6f89892d8c
commit 2c09cb4d7c
11 changed files with 486 additions and 219 deletions

View File

@ -18,13 +18,11 @@ import CaseSearch from "./_components/case-search"
const bentoGridItems: BentoGridItemProps[] = [
{
title: "Incident Map",
description: "Recent crime locations in the district",
icon: <MapPin className="w-5 h-5" />,
colSpan: "2",
rowSpan: "2",
// suffixMenu: <YearSelector years={["2020", "2021", "2022", "2023", "2024"]} selectedYear="" onChange={() => { }} />,
component: <CrimeMap />,
className: "p-0"
},
{
title: "Crime Statistics",

View File

@ -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 (
<div className="space-y-3">
<div className="text-sm font-medium">Lapisan peta</div>
<div className="flex items-center space-x-2">
<Checkbox id="sambaran-petir" />
<Label htmlFor="sambaran-petir" className="text-xs text-white">
Sambaran petir
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox id="jalur-badai" defaultChecked />
<Label htmlFor="jalur-badai" className="text-xs text-white">
Jalur badai guntur
</Label>
</div>
<div className="mt-4">
<div className="text-sm font-medium mb-2">Waktu</div>
<RadioGroup defaultValue="4jam">
<div className="flex items-center space-x-2">
<RadioGroupItem value="4jam" id="4jam" />
<Label htmlFor="4jam" className="text-xs text-white">
4 Jam
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="10hari" id="10hari" />
<Label htmlFor="10hari" className="text-xs text-white">
10 Hari
</Label>
</div>
</RadioGroup>
</div>
</div>
)
}
class LayerControlMapboxControl implements IControl {
private container: HTMLElement;
private map?: Map;
private props: MapLayerControlProps;
private root: ReturnType<typeof createRoot> | 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(<LayerControlContent />);
}
}
export function MapLayerControl({ position = 'bottom-left', isFullscreen }: MapLayerControlProps) {
const control = useControl<LayerControlMapboxControl>(
() => new LayerControlMapboxControl({ position, isFullscreen }),
{ position }
);
useEffect(() => {
control.updateProps({ position, isFullscreen });
}, [control, position, isFullscreen]);
return null;
}

View File

@ -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 (
<div className="absolute top-2 right-2 z-10 bg-white bg-opacity-90 p-2 rounded-md shadow-lg flex flex-col gap-2 max-w-[220px]">
<div className="absolute top-20 right-2 z-10 bg-white bg-opacity-90 p-2 rounded-md shadow-lg flex flex-col gap-2 max-w-[220px]">
<div className="text-sm font-medium mb-1">Map Filters</div>
<div className="grid grid-cols-1 gap-2">

View File

@ -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 (
<div className="absolute bottom-20 right-2 bg-black/75 p-3 rounded-md z-10 text-white text-sm">
<div className="font-medium mb-2">Crime Rates</div>
<div className="space-y-1 mb-3">
<div className="flex items-center gap-2">
<span className="inline-block w-4 h-4 rounded-sm" style={{ backgroundColor: CRIME_RATE_COLORS.low }}></span>
<div className="flex flex-row text-xs font-semibold">
<div className={`flex items-center gap-1.5 py-0 px-8 rounded-l-md border-y border-1 `} style={{ backgroundColor: `${CRIME_RATE_COLORS.low}90` }}>
<span>Low</span>
</div>
<div className="flex items-center gap-2">
<span
className="inline-block w-4 h-4 rounded-sm"
style={{ backgroundColor: CRIME_RATE_COLORS.medium }}
></span>
<div className={`flex items-center gap-1.5 py-0 px-8 border-y border-1 `} style={{ backgroundColor: `${CRIME_RATE_COLORS.medium}90` }}>
<span>Medium</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-block w-4 h-4 rounded-sm" style={{ backgroundColor: CRIME_RATE_COLORS.high }}></span>
<div className={`flex items-center gap-1.5 py-0 px-8 rounded-r-md border-y border-1 `} style={{ backgroundColor: `${CRIME_RATE_COLORS.high}90` }}>
<span>High</span>
</div>
<div className="flex items-center gap-2">
<span
className="inline-block w-4 h-4 rounded-sm"
style={{ backgroundColor: CRIME_RATE_COLORS.critical }}
></span>
<span>Critical</span>
</div>
</div>
<div className="font-medium mb-2">Incident Types</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<AlertCircle size={16} className="text-red-500" />
<span>General</span>
</div>
<div className="flex items-center gap-2">
<PackageX size={16} className="text-amber-500" />
<span>Theft</span>
</div>
<div className="flex items-center gap-2">
<Siren size={16} className="text-blue-500" />
<span>Accident</span>
</div>
<div className="flex items-center gap-2">
<ShieldAlert size={16} className="text-purple-500" />
<span>Violence</span>
</div>
<div className="flex items-center gap-2">
<AlertTriangle size={16} className="text-orange-500" />
<span>Juvenile</span>
</div>
</div>
</div>
)
}
class MapLegendControl implements IControl {
private container: HTMLElement;
private map?: Map;
private props: MapLegendProps;
private root: ReturnType<typeof createRoot> | 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(<LegendContent />);
}
}
export function MapLegend({ position = 'bottom-right', isFullscreen }: MapLegendProps) {
const control = useControl<MapLegendControl>(
() => new MapLegendControl({ position, isFullscreen }),
{ position }
);
// Update control when props change
useEffect(() => {
control.updateProps({ position, isFullscreen });
}, [control, position, isFullscreen]);
return null;
}

View File

@ -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 (
<div className="absolute bottom-0 left-0 right-0 z-20 bg-black/80 text-white">
<div className="flex items-center justify-between px-4 py-1">
<Button variant="ghost" size="icon" className="text-white hover:bg-white/20 rounded-full">
<Play className="h-5 w-5" />
</Button>
<div className="text-xs text-center">{formattedDate}</div>
</div>
<div className="relative h-8 border-t border-gray-700">
{/* Current time indicator */}
<div
className="absolute top-0 bottom-0 w-0.5 bg-yellow-400 z-10"
style={{ left: `${(times.indexOf(selectedTime) / (times.length - 1)) * 100}%` }}
></div>
{/* Time slots */}
<div className="flex h-full">
{times.map((time, index) => (
<div
key={time}
className={`flex-1 border-r border-gray-700 flex items-center justify-center cursor-pointer hover:bg-gray-800 ${time === selectedTime ? "bg-gray-800" : ""
}`}
onClick={() => onTimeChange(time)}
>
<span className="text-xs">{time}</span>
</div>
))}
</div>
{/* Legend at the bottom */}
<div className="flex justify-end px-4 py-1 bg-gray-900">
<div className="flex items-center gap-4">
<div className="text-xs">Tinggi</div>
<div className="flex">
<div className="w-16 h-4 bg-purple-600 text-xs text-center text-white">0-30 mnt</div>
<div className="w-16 h-4 bg-pink-600 text-xs text-center text-white">30-60 mnt</div>
<div className="w-16 h-4 bg-red-600 text-xs text-center text-white">60-90 mnt</div>
<div className="w-16 h-4 bg-orange-600 text-xs text-center text-white">90-120 mnt</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -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 = (
<MapFilterControl
selectedYear={selectedYear}
selectedMonth={selectedMonth}
availableYears={availableYears || []}
yearsLoading={yearsLoading}
onYearChange={setSelectedYear}
onMonthChange={setSelectedMonth}
onApplyFilters={applyFilters}
onResetFilters={resetFilters}
/>
)
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<Card className="w-full p-0 border-none shadow-none h-96">
<CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between">
<CardTitle>Crime Map {getMapTitle()}</CardTitle>
<div className="flex items-center gap-2">
{/* Regular (non-fullscreen) controls */}
@ -146,12 +131,12 @@ export default function CrimeMap() {
Apply
</Button>
<Button variant="ghost" onClick={resetFilters} disabled={selectedYear === 2024 && selectedMonth === "all"}>
<FilterX className="h-4 w-4 mr-2" />
<FilterX className="h-4 w-4" />
Reset
</Button>
<Button variant="outline" onClick={() => setShowLegend(!showLegend)}>
{/* <Button variant="outline" onClick={() => setShowLegend(!showLegend)}>
{showLegend ? "Hide Legend" : "Show Legend"}
</Button>
</Button> */}
</div>
</CardHeader>
<CardContent className="p-0">
@ -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 && <MapLegend />}
{<MapLegend />}
{/* District Layer with crime data */}
<DistrictLayer
@ -186,11 +176,6 @@ export default function CrimeMap() {
month={selectedMonth.toString()}
/>
{/* Display all crime incident markers */}
{/* {allIncidents?.map((incident) => (
<CrimeMarker key={incident.id} incident={incident} onClick={handleIncidentClick} />
))} */}
{/* Popup for selected incident */}
{selectedIncident && (
<CrimePopup

View File

@ -163,8 +163,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,
]
}),
@ -210,62 +208,62 @@ export default function DistrictLayer({
)
}
if (!map.getMap().getLayer("district-labels")) {
// Add district labels with improved visibility and responsive sizing
map.getMap().addLayer(
{
id: "district-labels",
type: "symbol",
source: "districts",
"source-layer": "Districts",
layout: {
"text-field": ["get", "nama"],
"text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
// Make text size responsive to zoom level
"text-size": [
"interpolate",
["linear"],
["zoom"],
9,
8, // At zoom level 9, size 8px
12,
12, // At zoom level 12, size 12px
15,
14, // At zoom level 15, size 14px
],
"text-allow-overlap": false,
"text-ignore-placement": false,
// Adjust text anchor based on zoom level
"text-anchor": "center",
"text-justify": "center",
"text-max-width": 8,
// Show labels only at certain zoom levels
"text-optional": true,
"symbol-sort-key": ["get", "kode_kec"], // Sort labels by district code
"symbol-z-order": "source",
},
paint: {
"text-color": "#000000",
"text-halo-color": "#ffffff",
"text-halo-width": 2,
"text-halo-blur": 1,
// Fade in text opacity based on zoom level
"text-opacity": [
"interpolate",
["linear"],
["zoom"],
8,
0, // Fully transparent at zoom level 8
9,
0.6, // 60% opacity at zoom level 9
10,
1.0, // Fully opaque at zoom level 10
],
},
},
firstSymbolId,
)
}
// if (!map.getMap().getLayer("district-labels")) {
// // Add district labels with improved visibility and responsive sizing
// map.getMap().addLayer(
// {
// id: "district-labels",
// type: "symbol",
// source: "districts",
// "source-layer": "Districts",
// layout: {
// "text-field": ["get", "nama"],
// "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
// // Make text size responsive to zoom level
// "text-size": [
// "interpolate",
// ["linear"],
// ["zoom"],
// 9,
// 8, // At zoom level 9, size 8px
// 12,
// 12, // At zoom level 12, size 12px
// 15,
// 14, // At zoom level 15, size 14px
// ],
// "text-allow-overlap": false,
// "text-ignore-placement": false,
// // Adjust text anchor based on zoom level
// "text-anchor": "center",
// "text-justify": "center",
// "text-max-width": 8,
// // Show labels only at certain zoom levels
// "text-optional": true,
// "symbol-sort-key": ["get", "kode_kec"], // Sort labels by district code
// "symbol-z-order": "source",
// },
// paint: {
// "text-color": "#000000",
// "text-halo-color": "#ffffff",
// "text-halo-width": 2,
// "text-halo-blur": 1,
// // Fade in text opacity based on zoom level
// "text-opacity": [
// "interpolate",
// ["linear"],
// ["zoom"],
// 8,
// 0, // Fully transparent at zoom level 8
// 9,
// 0.6, // 60% opacity at zoom level 9
// 10,
// 1.0, // Fully opaque at zoom level 10
// ],
// },
// },
// firstSymbolId,
// )
// }
// Create a source for clustered incident markers
if (crimes.length > 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,
]
}),

View File

@ -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<ViewState>
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<MapRef | null>(null)
const [activeControl, setActiveControl] = useState<string>("crime-rate")
const [activeTime, setActiveTime] = useState<string>("today")
const [sidebarOpen, setSidebarOpen] = useState<boolean>(false)
const mapContainerRef = useRef<HTMLDivElement>(null)
// Use the custom fullscreen hook instead of manual event listeners
const { isFullscreen } = useFullscreen(mapContainerRef)
const defaultViewState: Partial<ViewState> = {
@ -89,12 +103,10 @@ export default function MapView({
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 (
<div ref={mapContainerRef} className={`relative ${className}`}>
{/* Custom controls - only show when in fullscreen mode */}
{isFullscreen && (
<>
{/* Sidebar */}
<MapSidebar
isOpen={sidebarOpen}
onToggle={toggleSidebar}
crimes={crimes}
selectedYear={selectedYear}
selectedMonth={selectedMonth}
/>
<SidebarToggle isOpen={sidebarOpen} onToggle={toggleSidebar} />
{/* Additional controls that should only appear in fullscreen */}
<div className="absolute top-2 right-2 z-10 flex items-center bg-white rounded-md shadow-md">
<input
type="text"
placeholder="Search location..."
className="px-3 py-2 rounded-l-md border-0 focus:outline-none w-64"
/>
<button className="bg-gray-100 p-2 rounded-r-md">
<Search size={20} />
</button>
</div>
<MapControls onControlChange={handleControlChange} activeControl={activeControl} />
<TimeControls onTimeChange={handleTimeChange} activeTime={activeTime} />
<SeverityIndicator />
{/* Make sure customControls is displayed in fullscreen mode */}
{customControls}
</>
)}
{/* Main content with left padding when sidebar is open */}
<div className={`relative h-full transition-all duration-300 ${isFullscreen && sidebarOpen ? "ml-80" : "ml-0"}`}>
<ReactMapGL
@ -148,27 +126,52 @@ export default function MapView({
onMoveEnd={handleMoveEnd}
interactiveLayerIds={["district-fill", "clusters", "unclustered-point"]}
attributionControl={false}
style={{ width, height }}
style={{ width: "100%", height: "100%" }}
>
{children}
<NavigationControl position="right" />
<FullscreenControl
position="right"
containerId={mapContainerRef.current?.id}
/>
<ScaleControl position="bottom-left" />
<FullscreenControl position="top-right" />
<NavigationControl position="top-right" />
{/* GeolocateControl only shown in fullscreen mode */}
{/* Custom controls that depend on fullscreen state */}
{isFullscreen && <GeolocateControl position="top-right" />}
{/* Custom Filter Control with isFullscreen prop */}
{/* {isFullscreen && <MapFilterControl
position="top-right"
selectedYear={Number(selectedYear) || 2024}
selectedMonth={selectedMonth === "all" ? "all" : Number(selectedMonth) || "all"}
availableYears={availableYears}
yearsLoading={yearsLoading}
onYearChange={onYearChange}
onMonthChange={onMonthChange}
onApplyFilters={onApplyFilters}
onResetFilters={onResetFilters}
isFullscreen={isFullscreen}
/>
} */}
{/* Sidebar and other controls only in fullscreen */}
{isFullscreen && (
<GeolocateControl position="right" />
<>
<MapSidebar
isOpen={sidebarOpen}
onToggle={toggleSidebar}
crimes={crimes}
selectedYear={selectedYear}
selectedMonth={selectedMonth}
/>
<SidebarToggle isOpen={sidebarOpen} onToggle={toggleSidebar} />
<MapControls onControlChange={handleControlChange} activeControl={activeControl} />
<TimeControls onTimeChange={handleTimeChange} activeTime={activeTime} />
<SeverityIndicator />
</>
)}
</ReactMapGL>
</div>
{/* Debug indicator - remove in production */}
{/* Debug indicator - uncomment for debugging
<div className="absolute bottom-4 left-4 bg-black bg-opacity-70 text-white text-xs p-1 rounded z-50">
Fullscreen: {isFullscreen ? "Yes" : "No"}
</div>
</div> */}
</div>
)
}

View File

@ -2,14 +2,15 @@
import { useState, useEffect, RefObject } from 'react';
export function useFullscreen(ref: RefObject<HTMLElement | null>) {
/**
* 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<HTMLElement | null>) {
const [isFullscreen, setIsFullscreen] = useState<boolean>(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<HTMLElement | null>) {
(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<HTMLElement | null>) {
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<HTMLElement | null>) {
}
};
// Function to exit fullscreen
/**
* Exits fullscreen mode
*/
const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen();
@ -77,6 +79,9 @@ export function useFullscreen(ref: RefObject<HTMLElement | null>) {
}
};
/**
* Toggles fullscreen mode
*/
const toggleFullscreen = () => {
if (isFullscreen) {
exitFullscreen();
@ -85,5 +90,14 @@ export function useFullscreen(ref: RefObject<HTMLElement | null>) {
}
};
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,
};
}

View File

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

View File

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