feat: implement map layer control and time slider components for enhanced crime data visualization
This commit is contained in:
parent
6f89892d8c
commit
2c09cb4d7c
|
@ -18,13 +18,11 @@ import CaseSearch from "./_components/case-search"
|
||||||
|
|
||||||
const bentoGridItems: BentoGridItemProps[] = [
|
const bentoGridItems: BentoGridItemProps[] = [
|
||||||
{
|
{
|
||||||
title: "Incident Map",
|
|
||||||
description: "Recent crime locations in the district",
|
|
||||||
icon: <MapPin className="w-5 h-5" />,
|
|
||||||
colSpan: "2",
|
colSpan: "2",
|
||||||
rowSpan: "2",
|
rowSpan: "2",
|
||||||
// suffixMenu: <YearSelector years={["2020", "2021", "2022", "2023", "2024"]} selectedYear="" onChange={() => { }} />,
|
// suffixMenu: <YearSelector years={["2020", "2021", "2022", "2023", "2024"]} selectedYear="" onChange={() => { }} />,
|
||||||
component: <CrimeMap />,
|
component: <CrimeMap />,
|
||||||
|
className: "p-0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Crime Statistics",
|
title: "Crime Statistics",
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -3,7 +3,6 @@
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
|
||||||
import { Button } from "@/app/_components/ui/button"
|
import { Button } from "@/app/_components/ui/button"
|
||||||
import { FilterX } from "lucide-react"
|
import { FilterX } from "lucide-react"
|
||||||
import { getMonthName } from "@/app/_utils/common"
|
|
||||||
import { useCallback } from "react"
|
import { useCallback } from "react"
|
||||||
|
|
||||||
interface MapFilterControlProps {
|
interface MapFilterControlProps {
|
||||||
|
@ -53,7 +52,7 @@ export default function MapFilterControl({
|
||||||
const isDefaultFilter = selectedYear === 2024 && selectedMonth === "all"
|
const isDefaultFilter = selectedYear === 2024 && selectedMonth === "all"
|
||||||
|
|
||||||
return (
|
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="text-sm font-medium mb-1">Map Filters</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
|
|
@ -1,58 +1,109 @@
|
||||||
import { AlertCircle, AlertTriangle, PackageX, ShieldAlert, Siren } from "lucide-react"
|
|
||||||
import { CRIME_RATE_COLORS } from "@/app/_utils/const/map"
|
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 (
|
return (
|
||||||
<div className="absolute bottom-20 right-2 bg-black/75 p-3 rounded-md z-10 text-white text-sm">
|
<div className="flex flex-row text-xs font-semibold">
|
||||||
<div className="font-medium mb-2">Crime Rates</div>
|
<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` }}>
|
||||||
<div className="space-y-1 mb-3">
|
<span>Low</span>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="inline-block w-4 h-4 rounded-sm" style={{ backgroundColor: CRIME_RATE_COLORS.low }}></span>
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
<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>
|
||||||
|
<div className={`flex items-center gap-1.5 py-0 px-8 border-y border-1 `} style={{ backgroundColor: `${CRIME_RATE_COLORS.medium}90` }}>
|
||||||
<div className="font-medium mb-2">Incident Types</div>
|
<span>Medium</span>
|
||||||
<div className="space-y-1">
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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` }}>
|
||||||
<AlertCircle size={16} className="text-red-500" />
|
<span>High</span>
|
||||||
<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>
|
||||||
</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;
|
||||||
|
}
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -14,7 +14,6 @@ import { useState } from "react"
|
||||||
import { CrimePopup } from "./pop-up"
|
import { CrimePopup } from "./pop-up"
|
||||||
import CrimeMarker, { type CrimeIncident } from "./markers/crime-marker"
|
import CrimeMarker, { type CrimeIncident } from "./markers/crime-marker"
|
||||||
import { MapLegend } from "./controls/map-legend"
|
import { MapLegend } from "./controls/map-legend"
|
||||||
import MapFilterControl from "./controls/map-filter-control"
|
|
||||||
|
|
||||||
const months = [
|
const months = [
|
||||||
{ value: "1", label: "January" },
|
{ value: "1", label: "January" },
|
||||||
|
@ -89,23 +88,9 @@ export default function CrimeMap() {
|
||||||
return title
|
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 (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full p-0 border-none shadow-none h-96">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row pb-2 pt-0 px-0 items-center justify-between">
|
||||||
<CardTitle>Crime Map {getMapTitle()}</CardTitle>
|
<CardTitle>Crime Map {getMapTitle()}</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Regular (non-fullscreen) controls */}
|
{/* Regular (non-fullscreen) controls */}
|
||||||
|
@ -146,12 +131,12 @@ export default function CrimeMap() {
|
||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={resetFilters} disabled={selectedYear === 2024 && selectedMonth === "all"}>
|
<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
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setShowLegend(!showLegend)}>
|
{/* <Button variant="outline" onClick={() => setShowLegend(!showLegend)}>
|
||||||
{showLegend ? "Hide Legend" : "Show Legend"}
|
{showLegend ? "Hide Legend" : "Show Legend"}
|
||||||
</Button>
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
|
@ -173,10 +158,15 @@ export default function CrimeMap() {
|
||||||
crimes={crimes}
|
crimes={crimes}
|
||||||
selectedYear={selectedYear}
|
selectedYear={selectedYear}
|
||||||
selectedMonth={selectedMonth}
|
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 */}
|
{/* Show the legend regardless of fullscreen state if showLegend is true */}
|
||||||
{showLegend && <MapLegend />}
|
{<MapLegend />}
|
||||||
|
|
||||||
{/* District Layer with crime data */}
|
{/* District Layer with crime data */}
|
||||||
<DistrictLayer
|
<DistrictLayer
|
||||||
|
@ -186,11 +176,6 @@ export default function CrimeMap() {
|
||||||
month={selectedMonth.toString()}
|
month={selectedMonth.toString()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Display all crime incident markers */}
|
|
||||||
{/* {allIncidents?.map((incident) => (
|
|
||||||
<CrimeMarker key={incident.id} incident={incident} onClick={handleIncidentClick} />
|
|
||||||
))} */}
|
|
||||||
|
|
||||||
{/* Popup for selected incident */}
|
{/* Popup for selected incident */}
|
||||||
{selectedIncident && (
|
{selectedIncident && (
|
||||||
<CrimePopup
|
<CrimePopup
|
||||||
|
|
|
@ -163,8 +163,6 @@ export default function DistrictLayer({
|
||||||
? CRIME_RATE_COLORS.medium
|
? CRIME_RATE_COLORS.medium
|
||||||
: data.level === "high"
|
: data.level === "high"
|
||||||
? CRIME_RATE_COLORS.high
|
? CRIME_RATE_COLORS.high
|
||||||
: data.level === "critical"
|
|
||||||
? CRIME_RATE_COLORS.critical
|
|
||||||
: CRIME_RATE_COLORS.default,
|
: CRIME_RATE_COLORS.default,
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
|
@ -210,62 +208,62 @@ export default function DistrictLayer({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!map.getMap().getLayer("district-labels")) {
|
// if (!map.getMap().getLayer("district-labels")) {
|
||||||
// Add district labels with improved visibility and responsive sizing
|
// // Add district labels with improved visibility and responsive sizing
|
||||||
map.getMap().addLayer(
|
// map.getMap().addLayer(
|
||||||
{
|
// {
|
||||||
id: "district-labels",
|
// id: "district-labels",
|
||||||
type: "symbol",
|
// type: "symbol",
|
||||||
source: "districts",
|
// source: "districts",
|
||||||
"source-layer": "Districts",
|
// "source-layer": "Districts",
|
||||||
layout: {
|
// layout: {
|
||||||
"text-field": ["get", "nama"],
|
// "text-field": ["get", "nama"],
|
||||||
"text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
|
// "text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
|
||||||
// Make text size responsive to zoom level
|
// // Make text size responsive to zoom level
|
||||||
"text-size": [
|
// "text-size": [
|
||||||
"interpolate",
|
// "interpolate",
|
||||||
["linear"],
|
// ["linear"],
|
||||||
["zoom"],
|
// ["zoom"],
|
||||||
9,
|
// 9,
|
||||||
8, // At zoom level 9, size 8px
|
// 8, // At zoom level 9, size 8px
|
||||||
12,
|
// 12,
|
||||||
12, // At zoom level 12, size 12px
|
// 12, // At zoom level 12, size 12px
|
||||||
15,
|
// 15,
|
||||||
14, // At zoom level 15, size 14px
|
// 14, // At zoom level 15, size 14px
|
||||||
],
|
// ],
|
||||||
"text-allow-overlap": false,
|
// "text-allow-overlap": false,
|
||||||
"text-ignore-placement": false,
|
// "text-ignore-placement": false,
|
||||||
// Adjust text anchor based on zoom level
|
// // Adjust text anchor based on zoom level
|
||||||
"text-anchor": "center",
|
// "text-anchor": "center",
|
||||||
"text-justify": "center",
|
// "text-justify": "center",
|
||||||
"text-max-width": 8,
|
// "text-max-width": 8,
|
||||||
// Show labels only at certain zoom levels
|
// // Show labels only at certain zoom levels
|
||||||
"text-optional": true,
|
// "text-optional": true,
|
||||||
"symbol-sort-key": ["get", "kode_kec"], // Sort labels by district code
|
// "symbol-sort-key": ["get", "kode_kec"], // Sort labels by district code
|
||||||
"symbol-z-order": "source",
|
// "symbol-z-order": "source",
|
||||||
},
|
// },
|
||||||
paint: {
|
// paint: {
|
||||||
"text-color": "#000000",
|
// "text-color": "#000000",
|
||||||
"text-halo-color": "#ffffff",
|
// "text-halo-color": "#ffffff",
|
||||||
"text-halo-width": 2,
|
// "text-halo-width": 2,
|
||||||
"text-halo-blur": 1,
|
// "text-halo-blur": 1,
|
||||||
// Fade in text opacity based on zoom level
|
// // Fade in text opacity based on zoom level
|
||||||
"text-opacity": [
|
// "text-opacity": [
|
||||||
"interpolate",
|
// "interpolate",
|
||||||
["linear"],
|
// ["linear"],
|
||||||
["zoom"],
|
// ["zoom"],
|
||||||
8,
|
// 8,
|
||||||
0, // Fully transparent at zoom level 8
|
// 0, // Fully transparent at zoom level 8
|
||||||
9,
|
// 9,
|
||||||
0.6, // 60% opacity at zoom level 9
|
// 0.6, // 60% opacity at zoom level 9
|
||||||
10,
|
// 10,
|
||||||
1.0, // Fully opaque at zoom level 10
|
// 1.0, // Fully opaque at zoom level 10
|
||||||
],
|
// ],
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
firstSymbolId,
|
// firstSymbolId,
|
||||||
)
|
// )
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Create a source for clustered incident markers
|
// Create a source for clustered incident markers
|
||||||
if (crimes.length > 0 && !map.getMap().getSource("crime-incidents")) {
|
if (crimes.length > 0 && !map.getMap().getSource("crime-incidents")) {
|
||||||
|
@ -441,8 +439,6 @@ export default function DistrictLayer({
|
||||||
? CRIME_RATE_COLORS.medium
|
? CRIME_RATE_COLORS.medium
|
||||||
: data.level === "high"
|
: data.level === "high"
|
||||||
? CRIME_RATE_COLORS.high
|
? CRIME_RATE_COLORS.high
|
||||||
: data.level === "critical"
|
|
||||||
? CRIME_RATE_COLORS.critical
|
|
||||||
: CRIME_RATE_COLORS.default,
|
: CRIME_RATE_COLORS.default,
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
|
@ -506,8 +502,6 @@ export default function DistrictLayer({
|
||||||
? CRIME_RATE_COLORS.medium
|
? CRIME_RATE_COLORS.medium
|
||||||
: data.level === "high"
|
: data.level === "high"
|
||||||
? CRIME_RATE_COLORS.high
|
? CRIME_RATE_COLORS.high
|
||||||
: data.level === "critical"
|
|
||||||
? CRIME_RATE_COLORS.critical
|
|
||||||
: CRIME_RATE_COLORS.default,
|
: CRIME_RATE_COLORS.default,
|
||||||
]
|
]
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from "react"
|
import { useState, useCallback, useRef } from "react"
|
||||||
import ReactMapGL, {
|
import ReactMapGL, {
|
||||||
type ViewState,
|
type ViewState,
|
||||||
NavigationControl,
|
NavigationControl,
|
||||||
|
@ -11,21 +11,21 @@ import ReactMapGL, {
|
||||||
FullscreenControl,
|
FullscreenControl,
|
||||||
GeolocateControl,
|
GeolocateControl,
|
||||||
} from "react-map-gl/mapbox"
|
} 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 { Search } from "lucide-react"
|
||||||
import "mapbox-gl/dist/mapbox-gl.css"
|
import "mapbox-gl/dist/mapbox-gl.css"
|
||||||
import MapSidebar from "./controls/map-sidebar"
|
import MapSidebar from "./controls/map-sidebar"
|
||||||
import SidebarToggle from "./controls/map-toggle"
|
import SidebarToggle from "./controls/map-toggle"
|
||||||
import MapControls from "./controls/map-control"
|
|
||||||
import TimeControls from "./controls/time-control"
|
import TimeControls from "./controls/time-control"
|
||||||
import SeverityIndicator from "./controls/severity-indicator"
|
import SeverityIndicator from "./controls/severity-indicator"
|
||||||
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
|
|
||||||
import MapFilterControl from "./controls/map-filter-control"
|
import MapFilterControl from "./controls/map-filter-control"
|
||||||
|
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
|
||||||
|
import MapControls from "./controls/map-control"
|
||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
initialViewState?: Partial<ViewState>
|
initialViewState?: Partial<ViewState>
|
||||||
mapStyle?: string
|
mapStyle?: MapboxStyle
|
||||||
className?: string
|
className?: string
|
||||||
width?: string | number
|
width?: string | number
|
||||||
height?: string | number
|
height?: string | number
|
||||||
|
@ -42,12 +42,18 @@ interface MapViewProps {
|
||||||
}>
|
}>
|
||||||
selectedYear?: number | string
|
selectedYear?: number | string
|
||||||
selectedMonth?: 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({
|
export default function MapView({
|
||||||
children,
|
children,
|
||||||
initialViewState,
|
initialViewState,
|
||||||
mapStyle = MAP_STYLE,
|
mapStyle = MAPBOX_STYLES.Standard,
|
||||||
className = "w-full h-96",
|
className = "w-full h-96",
|
||||||
width = "100%",
|
width = "100%",
|
||||||
height = "100%",
|
height = "100%",
|
||||||
|
@ -57,12 +63,20 @@ export default function MapView({
|
||||||
crimes = [],
|
crimes = [],
|
||||||
selectedYear,
|
selectedYear,
|
||||||
selectedMonth,
|
selectedMonth,
|
||||||
|
availableYears = [],
|
||||||
|
yearsLoading = false,
|
||||||
|
onYearChange = () => { },
|
||||||
|
onMonthChange = () => { },
|
||||||
|
onApplyFilters = () => { },
|
||||||
|
onResetFilters = () => { },
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const [mapRef, setMapRef] = useState<MapRef | null>(null)
|
const [mapRef, setMapRef] = useState<MapRef | null>(null)
|
||||||
const [activeControl, setActiveControl] = useState<string>("crime-rate")
|
const [activeControl, setActiveControl] = useState<string>("crime-rate")
|
||||||
const [activeTime, setActiveTime] = useState<string>("today")
|
const [activeTime, setActiveTime] = useState<string>("today")
|
||||||
const [sidebarOpen, setSidebarOpen] = useState<boolean>(false)
|
const [sidebarOpen, setSidebarOpen] = useState<boolean>(false)
|
||||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Use the custom fullscreen hook instead of manual event listeners
|
||||||
const { isFullscreen } = useFullscreen(mapContainerRef)
|
const { isFullscreen } = useFullscreen(mapContainerRef)
|
||||||
|
|
||||||
const defaultViewState: Partial<ViewState> = {
|
const defaultViewState: Partial<ViewState> = {
|
||||||
|
@ -80,21 +94,19 @@ export default function MapView({
|
||||||
|
|
||||||
const handleMoveEnd = useCallback(
|
const handleMoveEnd = useCallback(
|
||||||
(event: any) => {
|
(event: any) => {
|
||||||
if (onMoveEnd) {
|
if (onMoveEnd) {
|
||||||
onMoveEnd(event.viewState)
|
onMoveEnd(event.viewState)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onMoveEnd],
|
[onMoveEnd],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleControlChange = (control: string) => {
|
const handleControlChange = (control: string) => {
|
||||||
setActiveControl(control)
|
setActiveControl(control)
|
||||||
// Here you would implement logic to change the map display based on the selected control
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTimeChange = (time: string) => {
|
const handleTimeChange = (time: string) => {
|
||||||
setActiveTime(time)
|
setActiveTime(time)
|
||||||
// Here you would implement logic to change the time period of data shown
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
|
@ -103,40 +115,6 @@ export default function MapView({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={mapContainerRef} className={`relative ${className}`}>
|
<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 */}
|
{/* Main content with left padding when sidebar is open */}
|
||||||
<div className={`relative h-full transition-all duration-300 ${isFullscreen && sidebarOpen ? "ml-80" : "ml-0"}`}>
|
<div className={`relative h-full transition-all duration-300 ${isFullscreen && sidebarOpen ? "ml-80" : "ml-0"}`}>
|
||||||
<ReactMapGL
|
<ReactMapGL
|
||||||
|
@ -148,27 +126,52 @@ export default function MapView({
|
||||||
onMoveEnd={handleMoveEnd}
|
onMoveEnd={handleMoveEnd}
|
||||||
interactiveLayerIds={["district-fill", "clusters", "unclustered-point"]}
|
interactiveLayerIds={["district-fill", "clusters", "unclustered-point"]}
|
||||||
attributionControl={false}
|
attributionControl={false}
|
||||||
style={{ width, height }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<NavigationControl position="right" />
|
<FullscreenControl position="top-right" />
|
||||||
<FullscreenControl
|
<NavigationControl position="top-right" />
|
||||||
position="right"
|
|
||||||
containerId={mapContainerRef.current?.id}
|
|
||||||
/>
|
|
||||||
<ScaleControl position="bottom-left" />
|
|
||||||
|
|
||||||
{/* 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 && (
|
{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>
|
</ReactMapGL>
|
||||||
</div>
|
</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">
|
<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"}
|
Fullscreen: {isFullscreen ? "Yes" : "No"}
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,14 +2,15 @@
|
||||||
|
|
||||||
import { useState, useEffect, RefObject } from 'react';
|
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);
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
|
||||||
|
|
||||||
const element = ref.current;
|
|
||||||
|
|
||||||
const handleFullscreenChange = () => {
|
const handleFullscreenChange = () => {
|
||||||
const fullscreenElement =
|
const fullscreenElement =
|
||||||
document.fullscreenElement ||
|
document.fullscreenElement ||
|
||||||
|
@ -17,19 +18,16 @@ export function useFullscreen(ref: RefObject<HTMLElement | null>) {
|
||||||
(document as any).mozFullScreenElement ||
|
(document as any).mozFullScreenElement ||
|
||||||
(document as any).msFullscreenElement;
|
(document as any).msFullscreenElement;
|
||||||
|
|
||||||
setIsFullscreen(
|
setIsFullscreen(!!fullscreenElement);
|
||||||
fullscreenElement === element ||
|
|
||||||
(fullscreenElement && element.contains(fullscreenElement))
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add event listeners for fullscreen changes
|
// Add event listeners for fullscreen changes across browsers
|
||||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||||
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
|
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
|
||||||
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
|
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
|
||||||
|
|
||||||
// Clean up event listeners
|
// Cleanup function to remove event listeners
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
document.removeEventListener(
|
document.removeEventListener(
|
||||||
|
@ -45,13 +43,15 @@ export function useFullscreen(ref: RefObject<HTMLElement | null>) {
|
||||||
handleFullscreenChange
|
handleFullscreenChange
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [ref]);
|
}, []);
|
||||||
|
|
||||||
// Function to request fullscreen
|
/**
|
||||||
|
* Requests fullscreen for the container element
|
||||||
|
*/
|
||||||
const enterFullscreen = () => {
|
const enterFullscreen = () => {
|
||||||
if (!ref.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
const element = ref.current;
|
const element = containerRef.current;
|
||||||
|
|
||||||
if (element.requestFullscreen) {
|
if (element.requestFullscreen) {
|
||||||
element.requestFullscreen();
|
element.requestFullscreen();
|
||||||
|
@ -64,7 +64,9 @@ export function useFullscreen(ref: RefObject<HTMLElement | null>) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to exit fullscreen
|
/**
|
||||||
|
* Exits fullscreen mode
|
||||||
|
*/
|
||||||
const exitFullscreen = () => {
|
const exitFullscreen = () => {
|
||||||
if (document.exitFullscreen) {
|
if (document.exitFullscreen) {
|
||||||
document.exitFullscreen();
|
document.exitFullscreen();
|
||||||
|
@ -77,6 +79,9 @@ export function useFullscreen(ref: RefObject<HTMLElement | null>) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggles fullscreen mode
|
||||||
|
*/
|
||||||
const toggleFullscreen = () => {
|
const toggleFullscreen = () => {
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
exitFullscreen();
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -770,3 +770,31 @@ export const getDistrictName = (districtId: string): string => {
|
||||||
'Unknown District'
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -9,8 +9,8 @@ export const MAPBOX_TILESET_ID = process.env.NEXT_PUBLIC_MAPBOX_TILESET_ID; // R
|
||||||
export const CRIME_RATE_COLORS = {
|
export const CRIME_RATE_COLORS = {
|
||||||
low: '#4ade80', // green
|
low: '#4ade80', // green
|
||||||
medium: '#facc15', // yellow
|
medium: '#facc15', // yellow
|
||||||
high: '#f97316', // orange
|
high: '#ef4444', // red
|
||||||
critical: '#ef4444', // red
|
// critical: '#ef4444', // red
|
||||||
default: '#94a3b8', // gray
|
default: '#94a3b8', // gray
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue