feat: Enhance Overlay component with customizable styles and addControl method
- Updated OverlayProps to include className and style for customization. - Modified OverlayControl to apply custom styles and handle control addition. - Improved rendering logic in _Overlay to pass addControl method to children. - Refactored SelectContent to accept a container prop for better positioning. - Cleaned up useFullscreen hook by removing unnecessary comments and improving readability. - Updated global CSS for better styling of map popups and controls. - Removed unused dependencies related to mapbox-gl-draw from package.json and package-lock.json. - Added CustomControl and MonthSelector components for enhanced map functionality.
This commit is contained in:
parent
31f12ab88b
commit
897a130ff7
|
@ -1,2 +1,30 @@
|
|||
// Ensure no usage of `supabase/server.ts` here
|
||||
// If needed, replace with `supabase/client.ts` for client-side functionality
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
getAvailableYears,
|
||||
getCrimeByYearAndMonth,
|
||||
getCrimes,
|
||||
} from '../action';
|
||||
|
||||
export const useGetAvailableYears = () => {
|
||||
return useQuery({
|
||||
queryKey: ['available-years'],
|
||||
queryFn: () => getAvailableYears(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCrimeByYearAndMonth = (
|
||||
year: number,
|
||||
month: number | 'all'
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ['crimes', year, month],
|
||||
queryFn: () => getCrimeByYearAndMonth(year, month),
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCrimes = () => {
|
||||
return useQuery({
|
||||
queryKey: ['crimes'],
|
||||
queryFn: () => getCrimes(),
|
||||
});
|
||||
};
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import { Map } from "mapbox-gl";
|
||||
|
||||
/* Idea from Stack Overflow https://stackoverflow.com/a/51683226 */
|
||||
export class CustomControl {
|
||||
private _className: string;
|
||||
private _title: string;
|
||||
private _eventHandler: (event: MouseEvent) => void;
|
||||
private _btn!: HTMLButtonElement;
|
||||
private _container!: HTMLDivElement;
|
||||
private _map?: Map;
|
||||
private _root: any; // React root for rendering our component
|
||||
|
||||
constructor({
|
||||
className = "",
|
||||
title = "",
|
||||
eventHandler = () => { }
|
||||
}: {
|
||||
className?: string;
|
||||
title?: string;
|
||||
eventHandler?: (event: MouseEvent) => void;
|
||||
}) {
|
||||
this._className = className;
|
||||
this._title = title;
|
||||
this._eventHandler = eventHandler;
|
||||
}
|
||||
|
||||
onAdd(map: Map) {
|
||||
this._map = map;
|
||||
this._btn = document.createElement("button");
|
||||
this._btn.className = "mapboxgl-ctrl-icon" + " " + this._className;
|
||||
this._btn.type = "button";
|
||||
this._btn.title = this._title;
|
||||
this._btn.onclick = this._eventHandler;
|
||||
|
||||
// Apply pointer-events: auto; style dynamically
|
||||
this._btn.style.pointerEvents = "auto";
|
||||
|
||||
// Dynamically append the style to the auto-generated className
|
||||
const styleSheet = document.styleSheets[0];
|
||||
styleSheet.insertRule(
|
||||
`.${this._className} { pointer-events: auto; }`,
|
||||
styleSheet.cssRules.length
|
||||
);
|
||||
|
||||
this._container = document.createElement("div");
|
||||
this._container.className = "mapboxgl-ctrl-group mapboxgl-ctrl";
|
||||
this._container.appendChild(this._btn);
|
||||
|
||||
return this._container;
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
if (this._container && this._container.parentNode) {
|
||||
this._container.parentNode.removeChild(this._container);
|
||||
}
|
||||
|
||||
// Defer unmounting React component to prevent race conditions
|
||||
if (this._root) {
|
||||
setTimeout(() => {
|
||||
this._root.unmount();
|
||||
});
|
||||
}
|
||||
|
||||
this._map = undefined;
|
||||
}
|
||||
}
|
|
@ -2,47 +2,38 @@
|
|||
import { Button } from "@/app/_components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/app/_components/ui/tooltip"
|
||||
import {
|
||||
Thermometer,
|
||||
Droplets,
|
||||
Wind,
|
||||
Cloud,
|
||||
Eye,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
MapIcon,
|
||||
BarChart3,
|
||||
Users,
|
||||
Siren,
|
||||
Building,
|
||||
Skull,
|
||||
} from "lucide-react"
|
||||
import { Overlay } from "../overlay"
|
||||
import { ControlPosition } from "mapbox-gl"
|
||||
import { IconBriefcaseOff, IconCategory, IconCategoryFilled } from "@tabler/icons-react"
|
||||
|
||||
interface MapControlsProps {
|
||||
interface MapMenusProps {
|
||||
onControlChange: (control: string) => void
|
||||
activeControl: string
|
||||
position?: ControlPosition
|
||||
position: ControlPosition
|
||||
}
|
||||
|
||||
export default function MapControls({ onControlChange, activeControl, position = "top-left" }: MapControlsProps) {
|
||||
const controls = [
|
||||
{ id: "crime-rate", icon: <Thermometer size={20} />, label: "Crime Rate" },
|
||||
{ id: "theft", icon: <Droplets size={20} />, label: "Theft" },
|
||||
{ id: "violence", icon: <Wind size={20} />, label: "Violence" },
|
||||
{ id: "vandalism", icon: <Cloud size={20} />, label: "Vandalism" },
|
||||
{ id: "traffic", icon: <Eye size={20} />, label: "Traffic" },
|
||||
{ id: "time", icon: <Clock size={20} />, label: "Time Analysis" },
|
||||
export default function MapMenus({ onControlChange, activeControl, position = "top-left" }: MapMenusProps) {
|
||||
const menus = [
|
||||
{ id: "crime-rate", icon: <Skull size={20} />, label: "Crime Rate" },
|
||||
{ id: "population", icon: <Users size={20} />, label: "Population" },
|
||||
{ id: "unemployment", icon: <IconBriefcaseOff size={20} />, label: "Unemployment" },
|
||||
{ id: "alerts", icon: <AlertTriangle size={20} className="text-amber-500" />, label: "Alerts" },
|
||||
{ id: "districts", icon: <MapIcon size={20} />, label: "Districts" },
|
||||
{ id: "statistics", icon: <BarChart3 size={20} />, label: "Statistics" },
|
||||
{ id: "demographics", icon: <Users size={20} />, label: "Demographics" },
|
||||
{ id: "emergency", icon: <Siren size={20} />, label: "Emergency" },
|
||||
{ id: "time", icon: <Clock size={20} />, label: "Time Analysis" },
|
||||
{ id: "unit", icon: <Building size={20} />, label: "Unit" },
|
||||
{ id: "category", icon: <IconCategory size={20} />, label: "Category" },
|
||||
]
|
||||
|
||||
return (
|
||||
<Overlay position={position}>
|
||||
|
||||
<div className="absolute top-0 left-0 z-10 bg-black/75 rounded-md m-2 p-1 flex items-center space-x-1">
|
||||
<TooltipProvider>
|
||||
{controls.map((control) => (
|
||||
{menus.map((control) => (
|
||||
<Tooltip key={control.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
@ -65,6 +56,6 @@ export default function MapControls({ onControlChange, activeControl, position =
|
|||
))}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</Overlay>
|
||||
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
"use client"
|
||||
import { ChevronLeft, ChevronRight, Cloud, Droplets, Wind } from "lucide-react"
|
||||
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { ChevronLeft, Filter, Map, BarChart3, Info } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs"
|
||||
import { ScrollArea } from "@/app/_components/ui/scroll-area"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/app/_components/ui/card"
|
||||
import { Separator } from "@/app/_components/ui/separator"
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/app/_components/ui/collapsible"
|
||||
import { cn } from "@/app/_lib/utils"
|
||||
|
||||
interface MapSidebarProps {
|
||||
isOpen: boolean
|
||||
|
@ -12,297 +13,170 @@ interface MapSidebarProps {
|
|||
crimes?: Array<{
|
||||
id: string
|
||||
district_name: string
|
||||
distrcit_id?: string
|
||||
district_id?: string
|
||||
number_of_crime?: number
|
||||
level?: "low" | "medium" | "high" | "critical"
|
||||
incidents: any[]
|
||||
}>
|
||||
selectedYear?: number | string
|
||||
selectedMonth?: number | string
|
||||
weatherData?: {
|
||||
temperature: number
|
||||
condition: string
|
||||
humidity: number
|
||||
windSpeed: number
|
||||
forecast: Array<{
|
||||
time: string
|
||||
temperature: number
|
||||
condition: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export default function MapSidebar({ isOpen, onToggle, crimes = [], selectedYear, selectedMonth }: MapSidebarProps) {
|
||||
// Calculate some statistics for the sidebar
|
||||
const totalIncidents = crimes.reduce((total, district) => total + (district.number_of_crime || 0), 0)
|
||||
const highRiskDistricts = crimes.filter(
|
||||
(district) => district.level === "high" || district.level === "critical",
|
||||
).length
|
||||
const districtCount = crimes.length
|
||||
|
||||
export default function MapSidebar({
|
||||
isOpen,
|
||||
onToggle,
|
||||
crimes = [],
|
||||
selectedYear,
|
||||
selectedMonth,
|
||||
weatherData = {
|
||||
temperature: 78,
|
||||
condition: "Mostly cloudy",
|
||||
humidity: 65,
|
||||
windSpeed: 8,
|
||||
forecast: [
|
||||
{ time: "Now", temperature: 78, condition: "Cloudy" },
|
||||
{ time: "9:00 PM", temperature: 75, condition: "Cloudy" },
|
||||
{ time: "10:00 PM", temperature: 73, condition: "Cloudy" },
|
||||
{ time: "11:00 PM", temperature: 72, condition: "Cloudy" },
|
||||
{ time: "12:00 AM", temperature: 70, condition: "Cloudy" },
|
||||
],
|
||||
},
|
||||
}: MapSidebarProps) {
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-0 left-0 h-full bg-white dark:bg-gray-900 shadow-lg z-20 transition-all duration-300 ease-in-out ${
|
||||
isOpen ? "w-80" : "w-0"
|
||||
} overflow-hidden`}
|
||||
className={cn(
|
||||
"h-full bg-background border-r transition-all duration-300 flex flex-col",
|
||||
isOpen ? "w-80" : "w-0 overflow-hidden",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="font-semibold text-lg">Crime Map Explorer</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onToggle}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">Weather Information</h2>
|
||||
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8">
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="flex-1 flex flex-col">
|
||||
<TabsList className="grid grid-cols-4 mx-2 mt-2">
|
||||
<TabsTrigger value="overview">
|
||||
<Map className="h-4 w-4 mr-1" />
|
||||
<span className="sr-only sm:not-sr-only sm:inline-block">Overview</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="filters">
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
<span className="sr-only sm:not-sr-only sm:inline-block">Filters</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="stats">
|
||||
<BarChart3 className="h-4 w-4 mr-1" />
|
||||
<span className="sr-only sm:not-sr-only sm:inline-block">Stats</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info">
|
||||
<Info className="h-4 w-4 mr-1" />
|
||||
<span className="sr-only sm:not-sr-only sm:inline-block">Info</span>
|
||||
</TabsTrigger>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<Tabs defaultValue="current">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="current">Current</TabsTrigger>
|
||||
<TabsTrigger value="forecast">Forecast</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<ScrollArea className="flex-1 p-4">
|
||||
<TabsContent value="overview" className="mt-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>Crime Summary</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedYear}
|
||||
{selectedMonth !== "all" ? ` - Month ${selectedMonth}` : ""}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-muted-foreground">Total Incidents</span>
|
||||
<span className="text-2xl font-bold">{totalIncidents}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-muted-foreground">High Risk Areas</span>
|
||||
<span className="text-2xl font-bold">{highRiskDistricts}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-muted-foreground">Districts</span>
|
||||
<span className="text-2xl font-bold">{districtCount}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-muted-foreground">Data Points</span>
|
||||
<span className="text-2xl font-bold">
|
||||
{crimes.reduce((total, district) => total + district.incidents.length, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<TabsContent value="current" className="space-y-4 mt-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-2xl font-bold flex items-center justify-between">
|
||||
<span>{weatherData.temperature}°F</span>
|
||||
<span className="text-sm font-normal">{weatherData.condition}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Droplets className="h-4 w-4 text-blue-500" />
|
||||
<span>Humidity: {weatherData.humidity}%</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle>District Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-white dark:bg-gray-900">
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2 text-sm">District</th>
|
||||
<th className="text-right p-2 text-sm">Incidents</th>
|
||||
<th className="text-right p-2 text-sm">Level</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{crimes
|
||||
.sort((a, b) => (b.number_of_crime || 0) - (a.number_of_crime || 0))
|
||||
.map((district) => (
|
||||
<tr key={district.id} className="border-b hover:bg-muted/50">
|
||||
<td className="p-2 text-sm">{district.district_name}</td>
|
||||
<td className="text-right p-2 text-sm">{district.number_of_crime || 0}</td>
|
||||
<td className="text-right p-2 text-sm">
|
||||
<span
|
||||
className={`inline-block px-2 py-0.5 rounded-full text-xs ${
|
||||
district.level === "low"
|
||||
? "bg-green-100 text-green-800"
|
||||
: district.level === "medium"
|
||||
? "bg-yellow-100 text-yellow-800"
|
||||
: district.level === "high"
|
||||
? "bg-orange-100 text-orange-800"
|
||||
: district.level === "critical"
|
||||
? "bg-red-100 text-red-800"
|
||||
: "bg-gray-100 text-gray-800"
|
||||
}`}
|
||||
>
|
||||
{district.level || "N/A"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wind className="h-4 w-4 text-gray-500" />
|
||||
<span>Wind: {weatherData.windSpeed} mph</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TabsContent value="filters" className="mt-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filter Options</CardTitle>
|
||||
<CardDescription>Customize what you see on the map</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Crime Types</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm" className="justify-start">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Theft
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="justify-start">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Violence
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="justify-start">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Vandalism
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="justify-start">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Traffic
|
||||
</Button>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Today's Recommendations</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-1">🌂</div>
|
||||
<div className="text-xs font-medium">Umbrella</div>
|
||||
<div className="text-xs">No need</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Severity Levels</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm" className="justify-start">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Low
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="justify-start">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Medium
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="justify-start">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
High
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="justify-start">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Critical
|
||||
</Button>
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-1">🏞️</div>
|
||||
<div className="text-xs font-medium">Outdoors</div>
|
||||
<div className="text-xs text-red-500">Very poor</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Collapsible className="w-full">
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="flex w-full justify-between p-0 h-8">
|
||||
<span>Crime Statistics</span>
|
||||
<ChevronRight className="h-4 w-4 transition-transform ui-open:rotate-90" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 mt-2">
|
||||
{crimes.length > 0 ? (
|
||||
crimes.map((crime) => (
|
||||
<Card key={crime.id} className="bg-muted/50">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">{crime.district_name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-2 py-0.5 rounded-full",
|
||||
crime.level === "low" && "bg-green-100 text-green-800",
|
||||
crime.level === "medium" && "bg-yellow-100 text-yellow-800",
|
||||
crime.level === "high" && "bg-orange-100 text-orange-800",
|
||||
crime.level === "critical" && "bg-red-100 text-red-800",
|
||||
)}
|
||||
>
|
||||
{crime.number_of_crime}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">No crime data available</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</TabsContent>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Display Options</h3>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
<Button variant="outline" size="sm" className="justify-start">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Show District Labels
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="justify-start">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Show Incident Markers
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="justify-start">
|
||||
<input type="checkbox" className="mr-2" />
|
||||
Show Heatmap
|
||||
</Button>
|
||||
<TabsContent value="forecast" className="mt-4">
|
||||
<div className="space-y-3">
|
||||
{weatherData.forecast.map((item, index) => (
|
||||
<Card key={index}>
|
||||
<CardContent className="p-3 flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud className="h-5 w-5 text-blue-500" />
|
||||
<span>{item.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stats" className="mt-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Crime Statistics</CardTitle>
|
||||
<CardDescription>Analysis of crime data</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Crime by Type</h3>
|
||||
<div className="h-40 bg-muted rounded-md flex items-center justify-center">
|
||||
<span className="text-sm text-muted-foreground">Chart Placeholder</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{item.condition}</span>
|
||||
<span className="font-medium">{item.temperature}°</span>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Crime by Time of Day</h3>
|
||||
<div className="h-40 bg-muted rounded-md flex items-center justify-center">
|
||||
<span className="text-sm text-muted-foreground">Chart Placeholder</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Monthly Trend</h3>
|
||||
<div className="h-40 bg-muted rounded-md flex items-center justify-center">
|
||||
<span className="text-sm text-muted-foreground">Chart Placeholder</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="info" className="mt-0 space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>About This Map</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
This interactive crime map visualizes crime data across different districts. Use the controls to
|
||||
explore different aspects of the data.
|
||||
</p>
|
||||
|
||||
<h3 className="text-sm font-medium mb-2">Legend</h3>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="w-4 h-4 bg-green-500 rounded-sm mr-2"></div>
|
||||
<span className="text-sm">Low Crime Rate</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-4 h-4 bg-yellow-500 rounded-sm mr-2"></div>
|
||||
<span className="text-sm">Medium Crime Rate</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-4 h-4 bg-orange-500 rounded-sm mr-2"></div>
|
||||
<span className="text-sm">High Crime Rate</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="w-4 h-4 bg-red-500 rounded-sm mr-2"></div>
|
||||
<span className="text-sm">Critical Crime Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium mb-2">Data Sources</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Crime data is collected from official police reports and updated monthly. District boundaries are
|
||||
based on administrative regions.
|
||||
</p>
|
||||
|
||||
<h3 className="text-sm font-medium mb-2">Help & Support</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
For questions or support regarding this map, please contact the system administrator.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,29 +1,39 @@
|
|||
"use client"
|
||||
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { Menu } from "lucide-react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { Button } from "../../ui/button"
|
||||
import { cn } from "@/app/_lib/utils"
|
||||
import { Overlay } from "../overlay"
|
||||
import { ControlPosition } from "mapbox-gl"
|
||||
|
||||
interface SidebarToggleProps {
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
position?: ControlPosition
|
||||
position?: "left" | "right"
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function SidebarToggle({ isOpen, onToggle, position = "left" }: SidebarToggleProps) {
|
||||
if (isOpen) return null
|
||||
|
||||
export default function SidebarToggle({ isOpen, onToggle, position = "left", className }: SidebarToggleProps) {
|
||||
return (
|
||||
<Overlay position={position}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute top-4 left-4 z-20 bg-white shadow-md hover:bg-gray-100"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"absolute z-10 shadow-md h-8 w-8 bg-background border"
|
||||
)}
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
{isOpen ? (
|
||||
position === "left" ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)
|
||||
) : position === "left" ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">{isOpen ? "Close sidebar" : "Open sidebar"}</span>
|
||||
</Button>
|
||||
</Overlay>
|
||||
)
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
"use client"
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
// Month options
|
||||
const months = [
|
||||
{ value: "1", label: "January" },
|
||||
{ value: "2", label: "February" },
|
||||
{ value: "3", label: "March" },
|
||||
{ value: "4", label: "April" },
|
||||
{ value: "5", label: "May" },
|
||||
{ value: "6", label: "June" },
|
||||
{ value: "7", label: "July" },
|
||||
{ value: "8", label: "August" },
|
||||
{ value: "9", label: "September" },
|
||||
{ value: "10", label: "October" },
|
||||
{ value: "11", label: "November" },
|
||||
{ value: "12", label: "December" },
|
||||
]
|
||||
|
||||
interface MonthSelectorProps {
|
||||
selectedMonth: number | "all"
|
||||
onMonthChange: (month: number | "all") => void
|
||||
className?: string
|
||||
includeAllOption?: boolean
|
||||
}
|
||||
|
||||
export default function MonthSelector({
|
||||
selectedMonth,
|
||||
onMonthChange,
|
||||
className = "w-[120px]",
|
||||
includeAllOption = true
|
||||
}: MonthSelectorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// This will ensure that the document is only used in the client-side context
|
||||
setIsClient(true)
|
||||
})
|
||||
|
||||
const container = isClient ? document.getElementById("root") : null
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="mapboxgl-month-selector">
|
||||
<Select
|
||||
value={selectedMonth.toString()}
|
||||
onValueChange={(value) => onMonthChange(value === "all" ? "all" : Number(value))}
|
||||
>
|
||||
<SelectTrigger className={className}>
|
||||
<SelectValue placeholder="Month" />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
container={containerRef.current || container || undefined}
|
||||
style={{ zIndex: 2000 }}
|
||||
>
|
||||
{includeAllOption && <SelectItem value="all">All Months</SelectItem>}
|
||||
{months.map((month) => (
|
||||
<SelectItem key={month.value} value={month.value}>
|
||||
{month.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Export months constant so it can be reused elsewhere
|
||||
export { months }
|
|
@ -1,29 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { useRef, useEffect, useState } from "react"
|
||||
|
||||
interface YearSelectorProps {
|
||||
years: string[]
|
||||
selectedYear: string
|
||||
onChange: (year: string) => void
|
||||
availableYears?: (number | null)[]
|
||||
selectedYear: number
|
||||
onYearChange: (year: number) => void
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function YearSelector({ years, selectedYear, onChange }: YearSelectorProps) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-medium">Year:</span>
|
||||
<Select value={selectedYear} onValueChange={onChange}>
|
||||
<SelectTrigger className="h-8 w-[100px]">
|
||||
<SelectValue placeholder="Select Year" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map((year) => (
|
||||
<SelectItem key={year} value={year}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
interface YearSelectorProps {
|
||||
availableYears?: (number | null)[];
|
||||
selectedYear: number;
|
||||
onYearChange: (year: number) => void;
|
||||
isLoading?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// React component for the year selector UI
|
||||
function YearSelectorUI({
|
||||
availableYears = [],
|
||||
selectedYear,
|
||||
onYearChange,
|
||||
isLoading = false,
|
||||
className = "w-[120px]"
|
||||
}: YearSelectorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// This will ensure that the document is only used in the client-side context
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// Conditionally access the document only when running on the client
|
||||
const container = isClient ? document.getElementById("root") : null;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="mapboxgl-year-selector">
|
||||
{isLoading ? (
|
||||
<div className={`${className} h-9 rounded-md bg-gradient-to-r from-gray-200 via-gray-100 to-gray-200 animate-pulse`} />
|
||||
) : (
|
||||
<Select
|
||||
value={selectedYear.toString()}
|
||||
onValueChange={(value) => onYearChange(Number(value))}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger className={className}>
|
||||
<SelectValue placeholder="Year" />
|
||||
</SelectTrigger>
|
||||
{/* Ensure that the dropdown content renders correctly only on the client side */}
|
||||
<SelectContent
|
||||
container={containerRef.current || container || undefined}
|
||||
style={{ zIndex: 2000 }}
|
||||
>
|
||||
{availableYears
|
||||
?.filter((year) => year !== null)
|
||||
.map((year) => (
|
||||
<SelectItem key={year} value={year!.toString()}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Mapbox GL control class implementation
|
||||
export class YearSelectorControl {
|
||||
private _map: any;
|
||||
private _container!: HTMLElement;
|
||||
private _root: any;
|
||||
private props: YearSelectorProps;
|
||||
|
||||
constructor(props: YearSelectorProps) {
|
||||
this.props = props;
|
||||
}
|
||||
|
||||
onAdd(map: any) {
|
||||
this._map = map;
|
||||
this._container = document.createElement('div');
|
||||
this._container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group';
|
||||
this._container.style.padding = '5px';
|
||||
|
||||
// Set position to relative to keep dropdown content in context
|
||||
this._container.style.position = 'relative';
|
||||
// Higher z-index to ensure dropdown appears above map elements
|
||||
this._container.style.zIndex = '50';
|
||||
|
||||
// Create React root for rendering our component
|
||||
this._root = createRoot(this._container);
|
||||
this._root.render(<YearSelectorUI {...this.props} />);
|
||||
|
||||
return this._container;
|
||||
}
|
||||
|
||||
onRemove() {
|
||||
if (this._container && this._container.parentNode) {
|
||||
this._container.parentNode.removeChild(this._container);
|
||||
}
|
||||
|
||||
// Unmount React component properly
|
||||
if (this._root) {
|
||||
this._root.unmount();
|
||||
}
|
||||
|
||||
this._map = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Export original React component as default for backward compatibility
|
||||
export default function YearSelector(props: YearSelectorProps) {
|
||||
// This wrapper allows the component to be used both as a React component
|
||||
// and to help create a MapboxGL control
|
||||
return <YearSelectorUI {...props} />;
|
||||
}
|
|
@ -4,31 +4,20 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/app/_components/ui/c
|
|||
import { Skeleton } from "@/app/_components/ui/skeleton"
|
||||
import DistrictLayer, { type DistrictFeature } from "./layers/district-layer"
|
||||
import MapView from "./map"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { AlertCircle, FilterX } from "lucide-react"
|
||||
import { getMonthName } from "@/app/_utils/common"
|
||||
import { useCrimeMapHandler } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_handlers/crime-map-handlers"
|
||||
import { useState } from "react"
|
||||
import { useRef, useState } from "react"
|
||||
|
||||
import { CrimePopup } from "./pop-up"
|
||||
import CrimeMarker, { type CrimeIncident } from "./markers/crime-marker"
|
||||
|
||||
|
||||
const months = [
|
||||
{ value: "1", label: "January" },
|
||||
{ value: "2", label: "February" },
|
||||
{ value: "3", label: "March" },
|
||||
{ value: "4", label: "April" },
|
||||
{ value: "5", label: "May" },
|
||||
{ value: "6", label: "June" },
|
||||
{ value: "7", label: "July" },
|
||||
{ value: "8", label: "August" },
|
||||
{ value: "9", label: "September" },
|
||||
{ value: "10", label: "October" },
|
||||
{ value: "11", label: "November" },
|
||||
{ value: "12", label: "December" },
|
||||
]
|
||||
import type { CrimeIncident } from "./markers/crime-marker"
|
||||
import YearSelector from "./controls/year-selector"
|
||||
import MonthSelector from "./controls/month-selector"
|
||||
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
|
||||
import { Overlay } from "./overlay"
|
||||
import { useGetAvailableYears, useGetCrimeByYearAndMonth } from "@/app/(pages)/(admin)/dashboard/crime-management/crime-overview/_queries/queries"
|
||||
import MapLegend from "./controls/map-legend"
|
||||
|
||||
export default function CrimeMap() {
|
||||
// Set default year to 2024 instead of "all"
|
||||
|
@ -38,8 +27,14 @@ export default function CrimeMap() {
|
|||
const [selectedIncident, setSelectedIncident] = useState<CrimeIncident | null>(null)
|
||||
const [showLegend, setShowLegend] = useState<boolean>(true)
|
||||
|
||||
const { availableYears, yearsLoading, yearsError, crimes, crimesLoading, crimesError, refetchCrimes } =
|
||||
useCrimeMapHandler(selectedYear, selectedMonth)
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Use the custom fullscreen hook
|
||||
const { isFullscreen } = useFullscreen(mapContainerRef)
|
||||
|
||||
const { data: availableYears, isLoading: isYearsLoading, error: yearsError } = useGetAvailableYears()
|
||||
|
||||
const { data: crimes, isLoading: isCrimesLoading, error: isCrimesError, refetch: refetchCrimes } = useGetCrimeByYearAndMonth(selectedYear, selectedMonth)
|
||||
|
||||
// Extract all incidents from all districts for marker display
|
||||
const allIncidents =
|
||||
|
@ -90,82 +85,43 @@ export default function CrimeMap() {
|
|||
|
||||
return (
|
||||
<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">
|
||||
<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 */}
|
||||
<Select value={selectedYear.toString()} onValueChange={(value) => setSelectedYear(Number(value))}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="Year" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!yearsLoading &&
|
||||
availableYears
|
||||
?.filter((year) => year !== null)
|
||||
.map((year) => (
|
||||
<SelectItem key={year} value={year!.toString()}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Year selector component */}
|
||||
<YearSelector
|
||||
availableYears={availableYears}
|
||||
selectedYear={selectedYear}
|
||||
onYearChange={setSelectedYear}
|
||||
isLoading={isYearsLoading}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={selectedMonth.toString()}
|
||||
onValueChange={(value) => setSelectedMonth(value === "all" ? "all" : Number(value))}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="Month" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Months</SelectItem>
|
||||
{months.map((month) => (
|
||||
<SelectItem key={month.value} value={month.value}>
|
||||
{month.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Month selector component */}
|
||||
<MonthSelector selectedMonth={selectedMonth} onMonthChange={setSelectedMonth} />
|
||||
|
||||
<Button variant="outline" onClick={applyFilters}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={resetFilters} disabled={selectedYear === 2024 && selectedMonth === "all"}>
|
||||
<FilterX className="h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
{/* <Button variant="outline" onClick={() => setShowLegend(!showLegend)}>
|
||||
{showLegend ? "Hide Legend" : "Show Legend"}
|
||||
</Button> */}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{crimesLoading ? (
|
||||
{isCrimesLoading ? (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<Skeleton className="h-full w-full rounded-md" />
|
||||
</div>
|
||||
) : crimesError ? (
|
||||
) : isCrimesError ? (
|
||||
<div className="flex flex-col items-center justify-center h-96 gap-4">
|
||||
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||
<p className="text-center">Failed to load crime data. Please try again later.</p>
|
||||
<Button onClick={() => refetchCrimes()}>Retry</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-[600px]">
|
||||
<div className="relative h-[600px]" ref={mapContainerRef}>
|
||||
<MapView
|
||||
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||
className="h-[600px] w-full rounded-md"
|
||||
crimes={crimes}
|
||||
selectedYear={selectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
availableYears={availableYears}
|
||||
yearsLoading={yearsLoading}
|
||||
onYearChange={setSelectedYear}
|
||||
onMonthChange={setSelectedMonth}
|
||||
onApplyFilters={applyFilters}
|
||||
onResetFilters={resetFilters}
|
||||
>
|
||||
|
||||
{/* District Layer with crime data */}
|
||||
<DistrictLayer
|
||||
onClick={handleDistrictClick}
|
||||
|
@ -183,6 +139,42 @@ export default function CrimeMap() {
|
|||
crime={selectedIncident}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFullscreen && (
|
||||
<>
|
||||
<Overlay
|
||||
position="top-left"
|
||||
className="m-2 bg-transparent shadow-none p-0 border-none"
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded-md p-0 shadow-lg">
|
||||
<YearSelector
|
||||
availableYears={availableYears}
|
||||
selectedYear={selectedYear}
|
||||
onYearChange={setSelectedYear}
|
||||
isLoading={isYearsLoading}
|
||||
className=" gap-2 text-white"
|
||||
/>
|
||||
<MonthSelector
|
||||
selectedMonth={selectedMonth}
|
||||
onMonthChange={setSelectedMonth}
|
||||
className=" gap-2 hover:bg-red text-white"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className=" hover:bg-red text-white"
|
||||
onClick={resetFilters}
|
||||
disabled={selectedYear === 2024 && selectedMonth === "all"}
|
||||
size="sm"
|
||||
>
|
||||
<FilterX className="h-4 w-4 mr-1" />
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</Overlay>
|
||||
|
||||
<MapLegend position="bottom-right" />
|
||||
</>
|
||||
)}
|
||||
</MapView>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
@ -1,28 +1,16 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import {
|
||||
type ViewState,
|
||||
NavigationControl,
|
||||
type MapRef,
|
||||
FullscreenControl,
|
||||
GeolocateControl,
|
||||
Map,
|
||||
} from "react-map-gl/mapbox"
|
||||
import { useState, useCallback, useRef, useEffect } from "react"
|
||||
import { type ViewState, Map, type MapRef, NavigationControl } from "react-map-gl/mapbox"
|
||||
import { FullscreenControl } from "react-map-gl/mapbox"
|
||||
import { BASE_LATITUDE, BASE_LONGITUDE, BASE_ZOOM, MAPBOX_STYLES, type MapboxStyle } from "@/app/_utils/const/map"
|
||||
import "mapbox-gl/dist/mapbox-gl.css"
|
||||
import MapSidebar from "./controls/map-sidebar"
|
||||
import SidebarToggle from "./controls/map-toggle"
|
||||
import TimeControls from "./controls/time-control"
|
||||
import SeverityIndicator from "./controls/severity-indicator"
|
||||
import MapFilterControl from "./controls/map-filter-control"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { YearSelectorControl } from "./controls/year-selector"
|
||||
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
|
||||
import MapControls from "./controls/map-control"
|
||||
import { Overlay } from "./overlay"
|
||||
import MapLegend from "./controls/map-legend"
|
||||
|
||||
import { CustomControl } from "./controls/example"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface MapViewProps {
|
||||
children?: React.ReactNode
|
||||
|
@ -34,22 +22,6 @@ interface MapViewProps {
|
|||
mapboxApiAccessToken?: string
|
||||
onMoveEnd?: (viewState: ViewState) => void
|
||||
customControls?: React.ReactNode
|
||||
crimes?: Array<{
|
||||
id: string
|
||||
district_name: string
|
||||
district_id?: string
|
||||
number_of_crime?: number
|
||||
level?: "low" | "medium" | "high" | "critical"
|
||||
incidents: any[]
|
||||
}>
|
||||
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({
|
||||
|
@ -61,24 +33,9 @@ export default function MapView({
|
|||
height = "100%",
|
||||
mapboxApiAccessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN,
|
||||
onMoveEnd,
|
||||
customControls,
|
||||
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> = {
|
||||
|
@ -90,87 +47,32 @@ export default function MapView({
|
|||
...initialViewState,
|
||||
}
|
||||
|
||||
const handleMapLoad = useCallback((event: any) => {
|
||||
setMapRef(event.target)
|
||||
}, [])
|
||||
|
||||
const handleMoveEnd = useCallback(
|
||||
(event: any) => {
|
||||
if (onMoveEnd) {
|
||||
onMoveEnd(event.viewState)
|
||||
}
|
||||
},
|
||||
[onMoveEnd],
|
||||
[onMoveEnd]
|
||||
)
|
||||
|
||||
const handleControlChange = (control: string) => {
|
||||
setActiveControl(control)
|
||||
}
|
||||
|
||||
const handleTimeChange = (time: string) => {
|
||||
setActiveTime(time)
|
||||
}
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebarOpen(!sidebarOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={mapContainerRef} className={`relative ${className}`}>
|
||||
{/* Main content with left padding when sidebar is open */}
|
||||
<div className={`relative h-full transition-all duration-300 ${isFullscreen && sidebarOpen ? "ml-80" : "ml-0"}`}>
|
||||
<Map
|
||||
ref={(ref) => setMapRef(ref)}
|
||||
mapStyle={mapStyle}
|
||||
mapboxAccessToken={mapboxApiAccessToken}
|
||||
initialViewState={defaultViewState}
|
||||
onLoad={handleMapLoad}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
interactiveLayerIds={["district-fill", "clusters", "unclustered-point"]}
|
||||
attributionControl={false}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
{children}
|
||||
<FullscreenControl position="top-right" />
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
{/* Sidebar and other controls only in fullscreen */}
|
||||
{isFullscreen && (
|
||||
<>
|
||||
{/* <MapFilterControl
|
||||
selectedYear={Number(selectedYear) || 2024}
|
||||
selectedMonth={selectedMonth === "all" ? "all" : Number(selectedMonth) || "all"}
|
||||
availableYears={availableYears}
|
||||
yearsLoading={yearsLoading}
|
||||
onYearChange={onYearChange}
|
||||
onMonthChange={onMonthChange}
|
||||
onApplyFilters={onApplyFilters}
|
||||
onResetFilters={onResetFilters}
|
||||
/> */}
|
||||
|
||||
<MapSidebar
|
||||
isOpen={sidebarOpen}
|
||||
onToggle={toggleSidebar}
|
||||
crimes={crimes}
|
||||
selectedYear={selectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
/>
|
||||
|
||||
{/* Map Legend - positioned at bottom-right */}
|
||||
<MapLegend position="bottom-right" />
|
||||
|
||||
{/* Sidebar toggle - positioned on the left */}
|
||||
<SidebarToggle isOpen={sidebarOpen} onToggle={toggleSidebar} position="left" />
|
||||
|
||||
{/* Map Control - positioned at top-left */}
|
||||
<MapControls onControlChange={handleControlChange} activeControl={activeControl} position="top-left" />
|
||||
|
||||
{/* Time/year selector - positioned at bottom-left */}
|
||||
<TimeControls onTimeChange={handleTimeChange} activeTime={activeTime} position="bottom-left" />
|
||||
|
||||
</>
|
||||
)}
|
||||
</Map>
|
||||
<div className="flex h-full">
|
||||
<div className="relative flex-grow h-full transition-all duration-300">
|
||||
<Map
|
||||
mapStyle={mapStyle}
|
||||
mapboxAccessToken={mapboxApiAccessToken}
|
||||
initialViewState={defaultViewState}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
attributionControl={false}
|
||||
>
|
||||
<FullscreenControl position="top-right" />
|
||||
<NavigationControl position="top-right" showCompass={false} />
|
||||
{children}
|
||||
</Map>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -5,32 +5,72 @@ import { createPortal } from "react-dom";
|
|||
import { useControl } from "react-map-gl/mapbox";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
// Updated props type to include addControl in children props
|
||||
type OverlayProps = {
|
||||
position: ControlPosition;
|
||||
children: ReactElement<{ map?: Map }>;
|
||||
children: ReactElement<{
|
||||
map?: Map;
|
||||
addControl?: (control: IControl, position?: ControlPosition) => void;
|
||||
}>;
|
||||
id?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
// Definisikan custom control untuk overlay
|
||||
// Custom control for overlay
|
||||
class OverlayControl implements IControl {
|
||||
_map: Map | null = null;
|
||||
_container: HTMLElement | null = null;
|
||||
_position: ControlPosition;
|
||||
_id: string;
|
||||
_redraw?: () => void;
|
||||
_className?: string;
|
||||
_style?: React.CSSProperties;
|
||||
|
||||
constructor({ position, id, redraw }: { position: ControlPosition; id: string; redraw?: () => void }) {
|
||||
constructor({
|
||||
position,
|
||||
id,
|
||||
redraw,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
position: ControlPosition;
|
||||
id: string;
|
||||
redraw?: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
this._position = position;
|
||||
this._id = id;
|
||||
this._redraw = redraw;
|
||||
this._className = className;
|
||||
this._style = style;
|
||||
}
|
||||
|
||||
onAdd(map: Map) {
|
||||
this._map = map;
|
||||
this._container = document.createElement('div');
|
||||
this._container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group';
|
||||
|
||||
// Apply base classes but keep it minimal to avoid layout conflicts
|
||||
this._container.className = `mapboxgl-ctrl ${this._className || ''}`;
|
||||
this._container.id = this._id;
|
||||
|
||||
// Important: These styles make the overlay adapt to content
|
||||
this._container.style.pointerEvents = 'auto';
|
||||
this._container.style.display = 'inline-block'; // Allow container to size to content
|
||||
this._container.style.maxWidth = 'none'; // Remove any max-width constraints
|
||||
this._container.style.width = 'auto'; // Let width be determined by content
|
||||
this._container.style.height = 'auto'; // Let height be determined by content
|
||||
this._container.style.overflow = 'visible'; // Allow content to overflow if needed
|
||||
|
||||
// Apply any custom styles passed as props
|
||||
if (this._style) {
|
||||
Object.entries(this._style).forEach(([key, value]) => {
|
||||
// @ts-ignore - dynamically setting style properties
|
||||
this._container.style[key] = value;
|
||||
});
|
||||
}
|
||||
|
||||
if (this._redraw) {
|
||||
map.on('move', this._redraw);
|
||||
this._redraw();
|
||||
|
@ -61,20 +101,34 @@ class OverlayControl implements IControl {
|
|||
getElement() {
|
||||
return this._container;
|
||||
}
|
||||
|
||||
// Method to add other controls to the map
|
||||
addControl(control: IControl, position?: ControlPosition) {
|
||||
if (this._map) {
|
||||
this._map.addControl(control, position);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
// Komponen Overlay yang telah ditingkatkan
|
||||
function _Overlay({ position, children, id = `overlay-${uuidv4()}` }: OverlayProps) {
|
||||
// Enhanced Overlay component
|
||||
function _Overlay({ position, children, id = `overlay-${uuidv4()}`, className, style }: OverlayProps) {
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null);
|
||||
const [map, setMap] = useState<Map | null>(null)
|
||||
const [map, setMap] = useState<Map | null>(null);
|
||||
|
||||
// Gunakan useControl dengan ID unik untuk menghindari konflik
|
||||
// Use useControl with unique ID to avoid conflicts
|
||||
const ctrl = useControl<OverlayControl>(
|
||||
() => new OverlayControl({ position, id }),
|
||||
{ position } // Hanya menggunakan position yang valid dalam ControlOptions
|
||||
() =>
|
||||
new OverlayControl({
|
||||
position,
|
||||
id,
|
||||
className,
|
||||
style,
|
||||
}),
|
||||
{ position }
|
||||
);
|
||||
|
||||
// Update container dan map instance ketika control siap
|
||||
// Update container and map instance when control is ready
|
||||
useEffect(() => {
|
||||
if (ctrl) {
|
||||
setContainer(ctrl.getElement());
|
||||
|
@ -82,15 +136,20 @@ function _Overlay({ position, children, id = `overlay-${uuidv4()}` }: OverlayPro
|
|||
}
|
||||
}, [ctrl]);
|
||||
|
||||
// Hanya render jika container sudah siap
|
||||
// Only render if container is ready
|
||||
if (!container || !map) return null;
|
||||
|
||||
// Gunakan createPortal untuk merender children ke container
|
||||
// Use createPortal to render children to container and pass addControl method
|
||||
// return createPortal(
|
||||
// cloneElement(children, { map, addControl: ctrl.addControl.bind(ctrl) }),
|
||||
// container
|
||||
// );
|
||||
|
||||
return createPortal(
|
||||
cloneElement(children, { map }),
|
||||
container
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
// Export sebagai komponen memoized
|
||||
export const Overlay = memo(_Overlay);
|
||||
// Export as memoized component
|
||||
export const Overlay = memo(_Overlay);
|
||||
|
|
|
@ -67,34 +67,38 @@ const SelectScrollDownButton = React.forwardRef<
|
|||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
interface SelectContentProps extends React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> {
|
||||
container?: HTMLElement
|
||||
}
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
SelectContentProps
|
||||
>(({ className, children, position = "popper", container, ...props }, ref) => (
|
||||
<SelectPrimitive.Portal container={container}>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
|
|
@ -2,11 +2,6 @@
|
|||
|
||||
import { useState, useEffect, RefObject } from 'react';
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
|
@ -21,13 +16,11 @@ export function useFullscreen(containerRef: RefObject<HTMLElement | null>) {
|
|||
setIsFullscreen(!!fullscreenElement);
|
||||
};
|
||||
|
||||
// Add event listeners for fullscreen changes across browsers
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
|
||||
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
|
||||
|
||||
// Cleanup function to remove event listeners
|
||||
return () => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
document.removeEventListener(
|
||||
|
@ -45,9 +38,6 @@ export function useFullscreen(containerRef: RefObject<HTMLElement | null>) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Requests fullscreen for the container element
|
||||
*/
|
||||
const enterFullscreen = () => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
|
@ -64,9 +54,6 @@ export function useFullscreen(containerRef: RefObject<HTMLElement | null>) {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exits fullscreen mode
|
||||
*/
|
||||
const exitFullscreen = () => {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
|
@ -79,9 +66,6 @@ export function useFullscreen(containerRef: RefObject<HTMLElement | null>) {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles fullscreen mode
|
||||
*/
|
||||
const toggleFullscreen = () => {
|
||||
if (isFullscreen) {
|
||||
exitFullscreen();
|
||||
|
|
File diff suppressed because one or more lines are too long
|
@ -7,7 +7,6 @@
|
|||
"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",
|
||||
|
@ -1645,24 +1644,6 @@
|
|||
"@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",
|
||||
|
@ -1671,52 +1652,6 @@
|
|||
"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",
|
||||
|
@ -14080,12 +14015,6 @@
|
|||
"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",
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
"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",
|
||||
|
|
Loading…
Reference in New Issue