feat: add map controls and sidebar components for crime data visualization
- Implemented MapControls component for selecting various crime-related metrics. - Created MapFilterControl for filtering data by year and month. - Developed MapSidebar to display crime statistics and district information. - Added SidebarToggle for opening and closing the sidebar. - Introduced SeverityIndicator to visually represent crime severity levels. - Created TimeControls for selecting time frames for data analysis. - Added useFullscreen hook for managing fullscreen functionality.
This commit is contained in:
parent
29925dc1b9
commit
6f89892d8c
|
@ -219,7 +219,6 @@ export async function getCrimeByYearAndMonth(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
take: 10,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return crimes.map((crime) => {
|
return crimes.map((crime) => {
|
||||||
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
"use client"
|
||||||
|
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,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
interface MapControlsProps {
|
||||||
|
onControlChange: (control: string) => void
|
||||||
|
activeControl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MapControls({ onControlChange, activeControl }: 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" },
|
||||||
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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) => (
|
||||||
|
<Tooltip key={control.id}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={activeControl === control.id ? "default" : "ghost"}
|
||||||
|
size="icon"
|
||||||
|
className={`h-8 w-8 rounded-md ${activeControl === control.id
|
||||||
|
? "bg-white text-black hover:bg-white/90"
|
||||||
|
: "text-white hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
onClick={() => onControlChange(control.id)}
|
||||||
|
>
|
||||||
|
{control.icon}
|
||||||
|
<span className="sr-only">{control.label}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>{control.label}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,110 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/app/_components/ui/select"
|
||||||
|
import { Button } from "@/app/_components/ui/button"
|
||||||
|
import { FilterX } from "lucide-react"
|
||||||
|
import { getMonthName } from "@/app/_utils/common"
|
||||||
|
import { useCallback } from "react"
|
||||||
|
|
||||||
|
interface MapFilterControlProps {
|
||||||
|
selectedYear: number
|
||||||
|
selectedMonth: number | "all"
|
||||||
|
availableYears: (number | null)[]
|
||||||
|
yearsLoading: boolean
|
||||||
|
onYearChange: (year: number) => void
|
||||||
|
onMonthChange: (month: number | "all") => void
|
||||||
|
onApplyFilters: () => void
|
||||||
|
onResetFilters: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function MapFilterControl({
|
||||||
|
selectedYear,
|
||||||
|
selectedMonth,
|
||||||
|
availableYears,
|
||||||
|
yearsLoading,
|
||||||
|
onYearChange,
|
||||||
|
onMonthChange,
|
||||||
|
onApplyFilters,
|
||||||
|
onResetFilters
|
||||||
|
}: MapFilterControlProps) {
|
||||||
|
const handleYearChange = useCallback((value: string) => {
|
||||||
|
onYearChange(Number(value))
|
||||||
|
}, [onYearChange])
|
||||||
|
|
||||||
|
const handleMonthChange = useCallback((value: string) => {
|
||||||
|
onMonthChange(value === "all" ? "all" : Number(value))
|
||||||
|
}, [onMonthChange])
|
||||||
|
|
||||||
|
const isDefaultFilter = selectedYear === 2024 && selectedMonth === "all"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-2 right-2 z-10 bg-white bg-opacity-90 p-2 rounded-md shadow-lg flex flex-col gap-2 max-w-[220px]">
|
||||||
|
<div className="text-sm font-medium mb-1">Map Filters</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<Select value={selectedYear.toString()} onValueChange={handleYearChange}>
|
||||||
|
<SelectTrigger className="h-8 w-full">
|
||||||
|
<SelectValue placeholder="Year" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{!yearsLoading &&
|
||||||
|
availableYears
|
||||||
|
?.filter((year) => year !== null)
|
||||||
|
.map((year) => (
|
||||||
|
<SelectItem key={year} value={year!.toString()}>
|
||||||
|
{year}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={selectedMonth.toString()}
|
||||||
|
onValueChange={handleMonthChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button className="h-8 text-xs flex-1" variant="default" onClick={onApplyFilters}>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="h-8 text-xs"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onResetFilters}
|
||||||
|
disabled={isDefaultFilter}
|
||||||
|
>
|
||||||
|
<FilterX className="h-3 w-3 mr-1" />
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ import { CRIME_RATE_COLORS } from "@/app/_utils/const/map"
|
||||||
|
|
||||||
export function MapLegend() {
|
export function MapLegend() {
|
||||||
return (
|
return (
|
||||||
<div className="absolute bottom-2 right-2 bg-black bg-opacity-70 p-3 rounded-md z-10 text-white text-sm">
|
<div className="absolute bottom-20 right-2 bg-black/75 p-3 rounded-md z-10 text-white text-sm">
|
||||||
<div className="font-medium mb-2">Crime Rates</div>
|
<div className="font-medium mb-2">Crime Rates</div>
|
||||||
<div className="space-y-1 mb-3">
|
<div className="space-y-1 mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
|
@ -0,0 +1,310 @@
|
||||||
|
"use client"
|
||||||
|
import { Button } from "@/app/_components/ui/button"
|
||||||
|
import { ChevronLeft, Filter, Map, BarChart3, Info } from "lucide-react"
|
||||||
|
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"
|
||||||
|
|
||||||
|
interface MapSidebarProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
crimes?: Array<{
|
||||||
|
id: string
|
||||||
|
district_name: string
|
||||||
|
distrcit_id?: string
|
||||||
|
number_of_crime?: number
|
||||||
|
level?: "low" | "medium" | "high" | "critical"
|
||||||
|
incidents: any[]
|
||||||
|
}>
|
||||||
|
selectedYear?: number | string
|
||||||
|
selectedMonth?: number | 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
|
||||||
|
|
||||||
|
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`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/app/_components/ui/button"
|
||||||
|
import { Menu } from "lucide-react"
|
||||||
|
|
||||||
|
interface SidebarToggleProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SidebarToggle({ isOpen, onToggle }: SidebarToggleProps) {
|
||||||
|
if (isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-4 left-4 z-20 bg-white shadow-md hover:bg-gray-100"
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<Menu className="h-5 w-5" />
|
||||||
|
<span className="sr-only">Open sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
export default function SeverityIndicator() {
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 z-10 flex justify-center">
|
||||||
|
<div className="bg-black/75 rounded-t-md px-4 py-1 flex items-center space-x-4 text-white text-sm">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium mr-2">Low</span>
|
||||||
|
<span className="font-medium mr-2">Medium</span>
|
||||||
|
<span className="font-medium mr-2">High</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
"use client"
|
||||||
|
import { Checkbox } from "@/app/_components/ui/checkbox"
|
||||||
|
import { Label } from "@/app/_components/ui/label"
|
||||||
|
|
||||||
|
interface TimeControlsProps {
|
||||||
|
onTimeChange: (time: string) => void
|
||||||
|
activeTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimeControls({ onTimeChange, activeTime }: TimeControlsProps) {
|
||||||
|
const times = [
|
||||||
|
{ id: "today", label: "Hari ini" },
|
||||||
|
{ id: "yesterday", label: "Kemarin" },
|
||||||
|
{ id: "week", label: "Minggu" },
|
||||||
|
{ id: "month", label: "Bulan" },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 z-10 bg-black/75 rounded-md p-2 flex items-center space-x-4">
|
||||||
|
<div className="text-white font-medium mr-2">Waktu</div>
|
||||||
|
{times.map((time) => (
|
||||||
|
<div key={time.id} className="flex items-center space-x-2">
|
||||||
|
<Checkbox id={time.id} checked={activeTime === time.id} onCheckedChange={() => onTimeChange(time.id)} />
|
||||||
|
<Label htmlFor={time.id} className="text-white text-sm cursor-pointer">
|
||||||
|
{time.label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ 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" },
|
||||||
|
@ -36,6 +37,7 @@ export default function CrimeMap() {
|
||||||
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
|
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
|
||||||
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
||||||
const [selectedIncident, setSelectedIncident] = useState<CrimeIncident | null>(null)
|
const [selectedIncident, setSelectedIncident] = useState<CrimeIncident | null>(null)
|
||||||
|
const [showLegend, setShowLegend] = useState<boolean>(true)
|
||||||
|
|
||||||
const { availableYears, yearsLoading, yearsError, crimes, crimesLoading, crimesError, refetchCrimes } =
|
const { availableYears, yearsLoading, yearsError, crimes, crimesLoading, crimesError, refetchCrimes } =
|
||||||
useCrimeMapHandler(selectedYear, selectedMonth)
|
useCrimeMapHandler(selectedYear, selectedMonth)
|
||||||
|
@ -83,26 +85,40 @@ export default function CrimeMap() {
|
||||||
let title = `${selectedYear}`
|
let title = `${selectedYear}`
|
||||||
if (selectedMonth !== "all") {
|
if (selectedMonth !== "all") {
|
||||||
title += ` - ${getMonthName(Number(selectedMonth))}`
|
title += ` - ${getMonthName(Number(selectedMonth))}`
|
||||||
}
|
}
|
||||||
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">
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row 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 */}
|
||||||
<Select value={selectedYear.toString()} onValueChange={(value) => setSelectedYear(Number(value))}>
|
<Select value={selectedYear.toString()} onValueChange={(value) => setSelectedYear(Number(value))}>
|
||||||
<SelectTrigger className="w-[120px]">
|
<SelectTrigger className="w-[120px]">
|
||||||
<SelectValue placeholder="Year" />
|
<SelectValue placeholder="Year" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{/* Removed "All Years" option */}
|
|
||||||
{!yearsLoading &&
|
{!yearsLoading &&
|
||||||
availableYears
|
availableYears
|
||||||
?.filter((year) => year !== null)
|
?.filter((year) => year !== null)
|
||||||
.map((year) => (
|
.map((year) => (
|
||||||
<SelectItem key={year} value={year.toString()}>
|
<SelectItem key={year} value={year!.toString()}>
|
||||||
{year}
|
{year}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
@ -133,6 +149,9 @@ export default function CrimeMap() {
|
||||||
<FilterX className="h-4 w-4 mr-2" />
|
<FilterX className="h-4 w-4 mr-2" />
|
||||||
Reset
|
Reset
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => setShowLegend(!showLegend)}>
|
||||||
|
{showLegend ? "Hide Legend" : "Show Legend"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
|
@ -147,10 +166,17 @@ export default function CrimeMap() {
|
||||||
<Button onClick={() => refetchCrimes()}>Retry</Button>
|
<Button onClick={() => refetchCrimes()}>Retry</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative h-96">
|
<div className="relative h-[600px]">
|
||||||
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-96 w-full rounded-md">
|
<MapView
|
||||||
{/* Display the legend */}
|
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||||
{/* <MapLegend /> */}
|
className="h-[600px] w-full rounded-md"
|
||||||
|
crimes={crimes}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
selectedMonth={selectedMonth}
|
||||||
|
customControls={mapFilterControls}
|
||||||
|
>
|
||||||
|
{/* Show the legend regardless of fullscreen state if showLegend is true */}
|
||||||
|
{showLegend && <MapLegend />}
|
||||||
|
|
||||||
{/* District Layer with crime data */}
|
{/* District Layer with crime data */}
|
||||||
<DistrictLayer
|
<DistrictLayer
|
||||||
|
@ -161,9 +187,9 @@ export default function CrimeMap() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Display all crime incident markers */}
|
{/* Display all crime incident markers */}
|
||||||
{allIncidents?.map((incident) => (
|
{/* {allIncidents?.map((incident) => (
|
||||||
<CrimeMarker key={incident.id} incident={incident} onClick={handleIncidentClick} />
|
<CrimeMarker key={incident.id} incident={incident} onClick={handleIncidentClick} />
|
||||||
))}
|
))} */}
|
||||||
|
|
||||||
{/* Popup for selected incident */}
|
{/* Popup for selected incident */}
|
||||||
{selectedIncident && (
|
{selectedIncident && (
|
||||||
|
|
|
@ -1,36 +1,36 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from "react"
|
||||||
import { useMap } from 'react-map-gl/mapbox';
|
import { useMap } from "react-map-gl/mapbox"
|
||||||
import { CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from '@/app/_utils/const/map';
|
import { CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from "@/app/_utils/const/map"
|
||||||
import { DistrictPopup } from '../pop-up';
|
import { DistrictPopup } from "../pop-up"
|
||||||
|
|
||||||
// Types for district properties
|
// Types for district properties
|
||||||
export interface DistrictFeature {
|
export interface DistrictFeature {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
properties: Record<string, any>;
|
properties: Record<string, any>
|
||||||
longitude?: number;
|
longitude?: number
|
||||||
latitude?: number;
|
latitude?: number
|
||||||
number_of_crime?: number;
|
number_of_crime?: number
|
||||||
level?: 'low' | 'medium' | 'high' | 'critical';
|
level?: "low" | "medium" | "high" | "critical"
|
||||||
}
|
}
|
||||||
|
|
||||||
// District layer props
|
// District layer props
|
||||||
export interface DistrictLayerProps {
|
export interface DistrictLayerProps {
|
||||||
visible?: boolean;
|
visible?: boolean
|
||||||
onClick?: (feature: DistrictFeature) => void;
|
onClick?: (feature: DistrictFeature) => void
|
||||||
year?: string;
|
year?: string
|
||||||
month?: string;
|
month?: string
|
||||||
crimes?: Array<{
|
crimes?: Array<{
|
||||||
id: string;
|
id: string
|
||||||
district_name: string;
|
district_name: string
|
||||||
distrcit_id?: string;
|
distrcit_id?: string
|
||||||
number_of_crime?: number;
|
number_of_crime?: number
|
||||||
level?: 'low' | 'medium' | 'high' | 'critical';
|
level?: "low" | "medium" | "high" | "critical"
|
||||||
incidents: any[];
|
incidents: any[]
|
||||||
}>;
|
}>
|
||||||
tilesetId?: string;
|
tilesetId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DistrictLayer({
|
export default function DistrictLayer({
|
||||||
|
@ -39,186 +39,489 @@ export default function DistrictLayer({
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
crimes = [],
|
crimes = [],
|
||||||
tilesetId = MAPBOX_TILESET_ID
|
tilesetId = MAPBOX_TILESET_ID,
|
||||||
}: DistrictLayerProps) {
|
}: DistrictLayerProps) {
|
||||||
const { current: map } = useMap();
|
const { current: map } = useMap()
|
||||||
|
|
||||||
const [hoverInfo, setHoverInfo] = useState<{
|
const [hoverInfo, setHoverInfo] = useState<{
|
||||||
x: number;
|
x: number
|
||||||
y: number;
|
y: number
|
||||||
feature: any;
|
feature: any
|
||||||
} | null>(null);
|
} | null>(null)
|
||||||
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null);
|
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
||||||
|
|
||||||
// Use a ref to track whether layers have been added
|
// Use a ref to track whether layers have been added
|
||||||
const layersAdded = useRef(false);
|
const layersAdded = useRef(false)
|
||||||
|
|
||||||
// Process crime data to map to districts by district_id (kode_kec)
|
// Process crime data to map to districts by district_id (kode_kec)
|
||||||
const crimeDataByDistrict = crimes.reduce((acc, crime) => {
|
const crimeDataByDistrict = crimes.reduce(
|
||||||
// We'll use kode_kec as the key to match with tileset properties
|
(acc, crime) => {
|
||||||
const districtId = crime.distrcit_id || crime.district_name;
|
// Use district_id (which corresponds to kode_kec in the tileset) as the key
|
||||||
|
const districtId = crime.distrcit_id || crime.district_name
|
||||||
|
|
||||||
acc[districtId] = {
|
console.log("Mapping district:", districtId, "level:", crime.level)
|
||||||
number_of_crime: crime.number_of_crime,
|
|
||||||
level: crime.level,
|
acc[districtId] = {
|
||||||
};
|
number_of_crime: crime.number_of_crime,
|
||||||
return acc;
|
level: crime.level,
|
||||||
}, {} as Record<string, { number_of_crime?: number; level?: 'low' | 'medium' | 'high' | 'critical' }>);
|
}
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
{} as Record<string, { number_of_crime?: number; level?: "low" | "medium" | "high" | "critical" }>,
|
||||||
|
)
|
||||||
|
|
||||||
// Handle click on district
|
// Handle click on district
|
||||||
const handleClick = (e: any) => {
|
const handleClick = (e: any) => {
|
||||||
if (!map || !e.features || e.features.length === 0) return;
|
if (!map || !e.features || e.features.length === 0) return
|
||||||
|
|
||||||
const feature = e.features[0];
|
const feature = e.features[0]
|
||||||
const districtId = feature.properties.kode_kec; // Using kode_kec as the unique identifier
|
const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier
|
||||||
const crimeData = crimeDataByDistrict[districtId] || {};
|
const crimeData = crimeDataByDistrict[districtId] || {}
|
||||||
|
|
||||||
const district: DistrictFeature = {
|
const district: DistrictFeature = {
|
||||||
id: districtId,
|
id: districtId,
|
||||||
name: feature.properties.nama || feature.properties.kecamatan,
|
name: feature.properties.nama || feature.properties.kecamatan,
|
||||||
properties: feature.properties,
|
properties: feature.properties,
|
||||||
longitude: e.lngLat.lng,
|
longitude: e.lngLat.lng,
|
||||||
latitude: e.lngLat.lat,
|
latitude: e.lngLat.lat,
|
||||||
...crimeData,
|
...crimeData,
|
||||||
};
|
}
|
||||||
|
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
onClick(district);
|
onClick(district)
|
||||||
} else {
|
} else {
|
||||||
setSelectedDistrict(district);
|
setSelectedDistrict(district)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Handle mouse move for hover effect
|
// Handle mouse move for hover effect
|
||||||
const handleMouseMove = (e: any) => {
|
const handleMouseMove = (e: any) => {
|
||||||
if (!map || !e.features || e.features.length === 0) return;
|
if (!map || !e.features || e.features.length === 0) return
|
||||||
|
|
||||||
const feature = e.features[0];
|
const feature = e.features[0]
|
||||||
const districtId = feature.properties.kode_kec; // Using kode_kec as the unique identifier
|
const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier
|
||||||
const crimeData = crimeDataByDistrict[districtId] || {};
|
const crimeData = crimeDataByDistrict[districtId] || {}
|
||||||
|
|
||||||
// Enhance feature with crime data
|
console.log("Hover district:", districtId, "found data:", crimeData)
|
||||||
feature.properties = {
|
|
||||||
...feature.properties,
|
|
||||||
...crimeData,
|
|
||||||
};
|
|
||||||
|
|
||||||
setHoverInfo({
|
// Enhance feature with crime data
|
||||||
x: e.point.x,
|
feature.properties = {
|
||||||
y: e.point.y,
|
...feature.properties,
|
||||||
feature: feature,
|
...crimeData,
|
||||||
});
|
}
|
||||||
};
|
|
||||||
|
setHoverInfo({
|
||||||
|
x: e.point.x,
|
||||||
|
y: e.point.y,
|
||||||
|
feature: feature,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Add district layer to the map when it's loaded
|
// Add district layer to the map when it's loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !visible || layersAdded.current) return;
|
if (!map || !visible) return
|
||||||
|
|
||||||
// Handler for style load event
|
// Handler for style load event
|
||||||
const onStyleLoad = () => {
|
const onStyleLoad = () => {
|
||||||
// Skip if layers are already added or map is not available
|
// Skip if map is not available
|
||||||
if (layersAdded.current || !map) return;
|
if (!map) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Add the vector tile source
|
// Check if the source already exists to prevent duplicates
|
||||||
map.getMap().addSource('districts', {
|
if (!map.getMap().getSource("districts")) {
|
||||||
type: 'vector',
|
// Get the first symbol layer ID from the map style
|
||||||
url: `mapbox://${tilesetId}`
|
// This ensures our layers appear below labels and POIs
|
||||||
});
|
const layers = map.getStyle().layers
|
||||||
|
let firstSymbolId: string | undefined
|
||||||
|
for (const layer of layers) {
|
||||||
|
if (layer.type === "symbol") {
|
||||||
|
firstSymbolId = layer.id
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add the fill layer for districts
|
// Add the vector tile source
|
||||||
map.getMap().addLayer({
|
map.getMap().addSource("districts", {
|
||||||
id: 'district-fill',
|
type: "vector",
|
||||||
type: 'fill',
|
url: `mapbox://${tilesetId}`,
|
||||||
source: 'districts',
|
})
|
||||||
'source-layer': 'Districts',
|
|
||||||
paint: {
|
|
||||||
'fill-color': [
|
|
||||||
'match',
|
|
||||||
['get', 'level'],
|
|
||||||
'low', CRIME_RATE_COLORS.low,
|
|
||||||
'medium', CRIME_RATE_COLORS.medium,
|
|
||||||
'high', CRIME_RATE_COLORS.high,
|
|
||||||
'critical', CRIME_RATE_COLORS.critical,
|
|
||||||
CRIME_RATE_COLORS.default
|
|
||||||
],
|
|
||||||
'fill-opacity': 0.6,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Create the dynamic fill color expression based on crime data
|
||||||
|
const fillColorExpression: any = [
|
||||||
|
"case",
|
||||||
|
["has", "kode_kec"],
|
||||||
|
[
|
||||||
|
"match",
|
||||||
|
["get", "kode_kec"],
|
||||||
|
...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
||||||
|
console.log("Initial color setting for:", districtId, "level:", data.level)
|
||||||
|
return [
|
||||||
|
districtId,
|
||||||
|
data.level === "low"
|
||||||
|
? CRIME_RATE_COLORS.low
|
||||||
|
: data.level === "medium"
|
||||||
|
? CRIME_RATE_COLORS.medium
|
||||||
|
: data.level === "high"
|
||||||
|
? CRIME_RATE_COLORS.high
|
||||||
|
: data.level === "critical"
|
||||||
|
? CRIME_RATE_COLORS.critical
|
||||||
|
: CRIME_RATE_COLORS.default,
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
CRIME_RATE_COLORS.default,
|
||||||
|
],
|
||||||
|
CRIME_RATE_COLORS.default,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Only add layers if they don't already exist
|
||||||
|
if (!map.getMap().getLayer("district-fill")) {
|
||||||
|
// Add the fill layer for districts with dynamic colors from the start
|
||||||
|
// Insert below the first symbol layer to preserve Mapbox default layers
|
||||||
|
map.getMap().addLayer(
|
||||||
|
{
|
||||||
|
id: "district-fill",
|
||||||
|
type: "fill",
|
||||||
|
source: "districts",
|
||||||
|
"source-layer": "Districts",
|
||||||
|
paint: {
|
||||||
|
"fill-color": fillColorExpression, // Apply colors based on crime data
|
||||||
|
"fill-opacity": 0.6,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
firstSymbolId,
|
||||||
|
) // Add before the first symbol layer
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getMap().getLayer("district-line")) {
|
||||||
// Add the line layer for district borders
|
// Add the line layer for district borders
|
||||||
map.getMap().addLayer({
|
map.getMap().addLayer(
|
||||||
id: 'district-line',
|
{
|
||||||
type: 'line',
|
id: "district-line",
|
||||||
source: 'districts',
|
type: "line",
|
||||||
'source-layer': 'Districts',
|
source: "districts",
|
||||||
paint: {
|
"source-layer": "Districts",
|
||||||
'line-color': '#ffffff',
|
paint: {
|
||||||
'line-width': 1,
|
"line-color": "#ffffff",
|
||||||
'line-opacity': 0.5,
|
"line-width": 1,
|
||||||
}
|
"line-opacity": 0.5,
|
||||||
});
|
},
|
||||||
|
},
|
||||||
// Set event handlers
|
firstSymbolId,
|
||||||
map.on('click', 'district-fill', handleClick);
|
)
|
||||||
map.on('mousemove', 'district-fill', handleMouseMove);
|
|
||||||
map.on('mouseleave', 'district-fill', () => setHoverInfo(null));
|
|
||||||
|
|
||||||
// Mark layers as added
|
|
||||||
layersAdded.current = true;
|
|
||||||
console.log('District layers added successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error adding district layers:', error);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// If the map's style is already loaded, add the layers immediately
|
if (!map.getMap().getLayer("district-labels")) {
|
||||||
if (map.isStyleLoaded()) {
|
// Add district labels with improved visibility and responsive sizing
|
||||||
onStyleLoad();
|
map.getMap().addLayer(
|
||||||
} else {
|
{
|
||||||
// Otherwise, wait for the style.load event
|
id: "district-labels",
|
||||||
map.once('style.load', onStyleLoad);
|
type: "symbol",
|
||||||
}
|
source: "districts",
|
||||||
|
"source-layer": "Districts",
|
||||||
// Cleanup function
|
layout: {
|
||||||
return () => {
|
"text-field": ["get", "nama"],
|
||||||
if (map && layersAdded.current) {
|
"text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
|
||||||
map.off('click', 'district-fill', handleClick);
|
// Make text size responsive to zoom level
|
||||||
map.off('mousemove', 'district-fill', handleMouseMove);
|
"text-size": [
|
||||||
map.off('mouseleave', 'district-fill', () => setHoverInfo(null));
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
// If we want to remove the layers and source on component unmount:
|
["zoom"],
|
||||||
if (map.getLayer('district-line')) map.getMap().removeLayer('district-line');
|
9,
|
||||||
if (map.getLayer('district-fill')) map.getMap().removeLayer('district-fill');
|
8, // At zoom level 9, size 8px
|
||||||
if (map.getSource('districts')) map.getMap().removeSource('districts');
|
12,
|
||||||
|
12, // At zoom level 12, size 12px
|
||||||
layersAdded.current = false;
|
15,
|
||||||
|
14, // At zoom level 15, size 14px
|
||||||
|
],
|
||||||
|
"text-allow-overlap": false,
|
||||||
|
"text-ignore-placement": false,
|
||||||
|
// Adjust text anchor based on zoom level
|
||||||
|
"text-anchor": "center",
|
||||||
|
"text-justify": "center",
|
||||||
|
"text-max-width": 8,
|
||||||
|
// Show labels only at certain zoom levels
|
||||||
|
"text-optional": true,
|
||||||
|
"symbol-sort-key": ["get", "kode_kec"], // Sort labels by district code
|
||||||
|
"symbol-z-order": "source",
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": "#000000",
|
||||||
|
"text-halo-color": "#ffffff",
|
||||||
|
"text-halo-width": 2,
|
||||||
|
"text-halo-blur": 1,
|
||||||
|
// Fade in text opacity based on zoom level
|
||||||
|
"text-opacity": [
|
||||||
|
"interpolate",
|
||||||
|
["linear"],
|
||||||
|
["zoom"],
|
||||||
|
8,
|
||||||
|
0, // Fully transparent at zoom level 8
|
||||||
|
9,
|
||||||
|
0.6, // 60% opacity at zoom level 9
|
||||||
|
10,
|
||||||
|
1.0, // Fully opaque at zoom level 10
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
firstSymbolId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}, [map, visible, tilesetId]);
|
// Create a source for clustered incident markers
|
||||||
|
if (crimes.length > 0 && !map.getMap().getSource("crime-incidents")) {
|
||||||
|
// Collect all incidents from all districts
|
||||||
|
const allIncidents = crimes.flatMap((crime) =>
|
||||||
|
crime.incidents.map((incident) => ({
|
||||||
|
type: "Feature" as const,
|
||||||
|
properties: {
|
||||||
|
id: incident.id,
|
||||||
|
district: crime.district_name,
|
||||||
|
category: incident.category,
|
||||||
|
incidentType: incident.type,
|
||||||
|
level: crime.level,
|
||||||
|
description: incident.description,
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: "Point" as const,
|
||||||
|
coordinates: [incident.longitude, incident.latitude],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add a clustered GeoJSON source for incidents
|
||||||
|
map.getMap().addSource("crime-incidents", {
|
||||||
|
type: "geojson",
|
||||||
|
data: {
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: allIncidents,
|
||||||
|
},
|
||||||
|
cluster: true,
|
||||||
|
clusterMaxZoom: 14,
|
||||||
|
clusterRadius: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only add layers if they don't already exist
|
||||||
|
if (!map.getMap().getLayer("clusters")) {
|
||||||
|
// Add a layer for the clusters - place below default symbol layers
|
||||||
|
map.getMap().addLayer(
|
||||||
|
{
|
||||||
|
id: "clusters",
|
||||||
|
type: "circle",
|
||||||
|
source: "crime-incidents",
|
||||||
|
filter: ["has", "point_count"],
|
||||||
|
paint: {
|
||||||
|
"circle-color": [
|
||||||
|
"step",
|
||||||
|
["get", "point_count"],
|
||||||
|
"#51bbd6", // Blue for small clusters
|
||||||
|
5,
|
||||||
|
"#f1f075", // Yellow for medium clusters
|
||||||
|
15,
|
||||||
|
"#f28cb1", // Pink for large clusters
|
||||||
|
],
|
||||||
|
"circle-radius": [
|
||||||
|
"step",
|
||||||
|
["get", "point_count"],
|
||||||
|
20, // Size for small clusters
|
||||||
|
5,
|
||||||
|
30, // Size for medium clusters
|
||||||
|
15,
|
||||||
|
40, // Size for large clusters
|
||||||
|
],
|
||||||
|
"circle-opacity": 0.75,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
firstSymbolId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getMap().getLayer("cluster-count")) {
|
||||||
|
// Add a layer for cluster counts
|
||||||
|
map.getMap().addLayer({
|
||||||
|
id: "cluster-count",
|
||||||
|
type: "symbol",
|
||||||
|
source: "crime-incidents",
|
||||||
|
filter: ["has", "point_count"],
|
||||||
|
layout: {
|
||||||
|
"text-field": "{point_count_abbreviated}",
|
||||||
|
"text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"],
|
||||||
|
"text-size": 12,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
"text-color": "#ffffff",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!map.getMap().getLayer("unclustered-point")) {
|
||||||
|
// Add a layer for individual incident points
|
||||||
|
map.getMap().addLayer(
|
||||||
|
{
|
||||||
|
id: "unclustered-point",
|
||||||
|
type: "circle",
|
||||||
|
source: "crime-incidents",
|
||||||
|
filter: ["!", ["has", "point_count"]],
|
||||||
|
paint: {
|
||||||
|
"circle-color": "#11b4da",
|
||||||
|
"circle-radius": 8,
|
||||||
|
"circle-stroke-width": 1,
|
||||||
|
"circle-stroke-color": "#fff",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
firstSymbolId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click handler for clusters
|
||||||
|
map.on("click", "clusters", (e) => {
|
||||||
|
const features = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })
|
||||||
|
|
||||||
|
if (!features || features.length === 0) return
|
||||||
|
|
||||||
|
const clusterId = features[0].properties?.cluster_id
|
||||||
|
|
||||||
|
// Get the cluster expansion zoom
|
||||||
|
; (map.getSource("crime-incidents") as mapboxgl.GeoJSONSource).getClusterExpansionZoom(
|
||||||
|
clusterId,
|
||||||
|
(err, zoom) => {
|
||||||
|
if (err) return
|
||||||
|
|
||||||
|
map.easeTo({
|
||||||
|
center: (features[0].geometry as any).coordinates,
|
||||||
|
zoom: zoom ?? undefined,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show pointer cursor on clusters and points
|
||||||
|
map.on("mouseenter", "clusters", () => {
|
||||||
|
map.getCanvas().style.cursor = "pointer"
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on("mouseleave", "clusters", () => {
|
||||||
|
map.getCanvas().style.cursor = ""
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on("mouseenter", "unclustered-point", () => {
|
||||||
|
map.getCanvas().style.cursor = "pointer"
|
||||||
|
})
|
||||||
|
|
||||||
|
map.on("mouseleave", "unclustered-point", () => {
|
||||||
|
map.getCanvas().style.cursor = ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set event handlers
|
||||||
|
map.on("click", "district-fill", handleClick)
|
||||||
|
map.on("mousemove", "district-fill", handleMouseMove)
|
||||||
|
map.on("mouseleave", "district-fill", () => setHoverInfo(null))
|
||||||
|
|
||||||
|
// Mark layers as added
|
||||||
|
layersAdded.current = true
|
||||||
|
console.log("District layers added successfully")
|
||||||
|
} else {
|
||||||
|
// If the source already exists, just update the data
|
||||||
|
console.log("District source already exists, updating data")
|
||||||
|
|
||||||
|
// Update the district-fill layer with new crime data if it exists
|
||||||
|
if (map.getMap().getLayer("district-fill")) {
|
||||||
|
map.getMap().setPaintProperty("district-fill", "fill-color", [
|
||||||
|
"case",
|
||||||
|
["has", "kode_kec"],
|
||||||
|
[
|
||||||
|
"match",
|
||||||
|
["get", "kode_kec"],
|
||||||
|
...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
||||||
|
return [
|
||||||
|
districtId,
|
||||||
|
data.level === "low"
|
||||||
|
? CRIME_RATE_COLORS.low
|
||||||
|
: data.level === "medium"
|
||||||
|
? CRIME_RATE_COLORS.medium
|
||||||
|
: data.level === "high"
|
||||||
|
? CRIME_RATE_COLORS.high
|
||||||
|
: data.level === "critical"
|
||||||
|
? CRIME_RATE_COLORS.critical
|
||||||
|
: CRIME_RATE_COLORS.default,
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
CRIME_RATE_COLORS.default,
|
||||||
|
],
|
||||||
|
CRIME_RATE_COLORS.default,
|
||||||
|
] as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error adding district layers:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the map's style is already loaded, add the layers immediately
|
||||||
|
if (map.isStyleLoaded()) {
|
||||||
|
onStyleLoad()
|
||||||
|
} else {
|
||||||
|
// Otherwise, wait for the style.load event
|
||||||
|
map.once("style.load", onStyleLoad)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
if (map) {
|
||||||
|
// Only remove event listeners, not the layers themselves
|
||||||
|
map.off("click", "district-fill", handleClick)
|
||||||
|
map.off("mousemove", "district-fill", handleMouseMove)
|
||||||
|
map.off("mouseleave", "district-fill", () => setHoverInfo(null))
|
||||||
|
|
||||||
|
// We're not removing the layers or sources here to avoid disrupting the map
|
||||||
|
// This prevents the issue of removing default layers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [map, visible, tilesetId, crimes])
|
||||||
|
|
||||||
// Update the crime data when it changes
|
// Update the crime data when it changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !layersAdded.current) return;
|
if (!map || !layersAdded.current) return
|
||||||
|
|
||||||
// Update the district-fill layer with new crime data
|
console.log("Updating district colors with data:", crimeDataByDistrict)
|
||||||
try {
|
|
||||||
// We need to update the layer paint property to correctly apply colors
|
// Update the district-fill layer with new crime data
|
||||||
map.getMap().setPaintProperty('district-fill', 'fill-color', [
|
try {
|
||||||
'match',
|
// Check if the layer exists before updating it
|
||||||
['coalesce', ['get', 'level'], 'default'],
|
if (map.getMap().getLayer("district-fill")) {
|
||||||
'low', CRIME_RATE_COLORS.low,
|
// We need to update the layer paint property to correctly apply colors
|
||||||
'medium', CRIME_RATE_COLORS.medium,
|
map.getMap().setPaintProperty("district-fill", "fill-color", [
|
||||||
'high', CRIME_RATE_COLORS.high,
|
"case",
|
||||||
'critical', CRIME_RATE_COLORS.critical,
|
["has", "kode_kec"],
|
||||||
CRIME_RATE_COLORS.default
|
[
|
||||||
]);
|
"match",
|
||||||
} catch (error) {
|
["get", "kode_kec"],
|
||||||
console.error('Error updating district layer:', error);
|
...Object.entries(crimeDataByDistrict).flatMap(([districtId, data]) => {
|
||||||
|
console.log("Setting color for:", districtId, "level:", data.level)
|
||||||
|
return [
|
||||||
|
districtId,
|
||||||
|
data.level === "low"
|
||||||
|
? CRIME_RATE_COLORS.low
|
||||||
|
: data.level === "medium"
|
||||||
|
? CRIME_RATE_COLORS.medium
|
||||||
|
: data.level === "high"
|
||||||
|
? CRIME_RATE_COLORS.high
|
||||||
|
: data.level === "critical"
|
||||||
|
? CRIME_RATE_COLORS.critical
|
||||||
|
: CRIME_RATE_COLORS.default,
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
CRIME_RATE_COLORS.default,
|
||||||
|
],
|
||||||
|
CRIME_RATE_COLORS.default,
|
||||||
|
] as any)
|
||||||
}
|
}
|
||||||
}, [map, crimes]);
|
} catch (error) {
|
||||||
|
console.error("Error updating district layer:", error)
|
||||||
|
}
|
||||||
|
}, [map, crimes])
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -238,26 +541,24 @@ export default function DistrictLayer({
|
||||||
<p className="text-xs text-gray-600">
|
<p className="text-xs text-gray-600">
|
||||||
{hoverInfo.feature.properties.number_of_crime} incidents
|
{hoverInfo.feature.properties.number_of_crime} incidents
|
||||||
{hoverInfo.feature.properties.level && (
|
{hoverInfo.feature.properties.level && (
|
||||||
<span className="ml-2 text-xs font-semibold text-gray-500">
|
<span className="ml-2 text-xs font-semibold text-gray-500">({hoverInfo.feature.properties.level})</span>
|
||||||
({hoverInfo.feature.properties.level})
|
)}
|
||||||
</span>
|
</p>
|
||||||
)}
|
)}
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* District popup */}
|
{/* District popup */}
|
||||||
{selectedDistrict && selectedDistrict.longitude && selectedDistrict.latitude && (
|
{selectedDistrict && selectedDistrict.longitude && selectedDistrict.latitude && (
|
||||||
<DistrictPopup
|
<DistrictPopup
|
||||||
longitude={selectedDistrict.longitude}
|
longitude={selectedDistrict.longitude}
|
||||||
latitude={selectedDistrict.latitude}
|
latitude={selectedDistrict.latitude}
|
||||||
onClose={() => setSelectedDistrict(null)}
|
onClose={() => setSelectedDistrict(null)}
|
||||||
district={selectedDistrict}
|
district={selectedDistrict}
|
||||||
year={year}
|
year={year}
|
||||||
month={month}
|
month={month}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,71 +1,174 @@
|
||||||
'use client';
|
"use client"
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import type React from "react"
|
||||||
import ReactMapGL, { ViewState, NavigationControl, ScaleControl, MapRef, FullscreenControl } from 'react-map-gl/mapbox';
|
|
||||||
import { BASE_LATITUDE, BASE_LONGITUDE, BASE_ZOOM, MAP_STYLE } from '@/app/_utils/const/map';
|
import { useState, useCallback, useEffect, useRef } from "react"
|
||||||
import 'mapbox-gl/dist/mapbox-gl.css';
|
import ReactMapGL, {
|
||||||
|
type ViewState,
|
||||||
|
NavigationControl,
|
||||||
|
ScaleControl,
|
||||||
|
type MapRef,
|
||||||
|
FullscreenControl,
|
||||||
|
GeolocateControl,
|
||||||
|
} from "react-map-gl/mapbox"
|
||||||
|
import { BASE_LATITUDE, BASE_LONGITUDE, BASE_ZOOM, MAP_STYLE } from "@/app/_utils/const/map"
|
||||||
|
import { Search } from "lucide-react"
|
||||||
|
import "mapbox-gl/dist/mapbox-gl.css"
|
||||||
|
import MapSidebar from "./controls/map-sidebar"
|
||||||
|
import SidebarToggle from "./controls/map-toggle"
|
||||||
|
import MapControls from "./controls/map-control"
|
||||||
|
import TimeControls from "./controls/time-control"
|
||||||
|
import SeverityIndicator from "./controls/severity-indicator"
|
||||||
|
import { useFullscreen } from "@/app/_hooks/use-fullscreen"
|
||||||
|
import MapFilterControl from "./controls/map-filter-control"
|
||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode
|
||||||
initialViewState?: Partial<ViewState>;
|
initialViewState?: Partial<ViewState>
|
||||||
mapStyle?: string;
|
mapStyle?: string
|
||||||
className?: string;
|
className?: string
|
||||||
width?: string | number;
|
width?: string | number
|
||||||
height?: string | number;
|
height?: string | number
|
||||||
mapboxApiAccessToken?: string;
|
mapboxApiAccessToken?: string
|
||||||
onMoveEnd?: (viewState: ViewState) => void;
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapView({
|
export default function MapView({
|
||||||
children,
|
children,
|
||||||
initialViewState,
|
initialViewState,
|
||||||
mapStyle = MAP_STYLE,
|
mapStyle = MAP_STYLE,
|
||||||
className = 'w-full h-96',
|
className = "w-full h-96",
|
||||||
width = '100%',
|
width = "100%",
|
||||||
height = '100%',
|
height = "100%",
|
||||||
mapboxApiAccessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN,
|
mapboxApiAccessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN,
|
||||||
onMoveEnd
|
onMoveEnd,
|
||||||
|
customControls,
|
||||||
|
crimes = [],
|
||||||
|
selectedYear,
|
||||||
|
selectedMonth,
|
||||||
}: MapViewProps) {
|
}: MapViewProps) {
|
||||||
const [mapRef, setMapRef] = useState<MapRef | null>(null);
|
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)
|
||||||
|
const { isFullscreen } = useFullscreen(mapContainerRef)
|
||||||
|
|
||||||
const defaultViewState: Partial<ViewState> = {
|
const defaultViewState: Partial<ViewState> = {
|
||||||
longitude: BASE_LONGITUDE, // Center of Jember region
|
longitude: BASE_LONGITUDE,
|
||||||
latitude: BASE_LATITUDE,
|
latitude: BASE_LATITUDE,
|
||||||
zoom: BASE_ZOOM,
|
zoom: BASE_ZOOM,
|
||||||
bearing: 0,
|
bearing: 0,
|
||||||
pitch: 0,
|
pitch: 0,
|
||||||
...initialViewState
|
...initialViewState,
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleMapLoad = useCallback((event: any) => {
|
const handleMapLoad = useCallback((event: any) => {
|
||||||
setMapRef(event.target);
|
setMapRef(event.target)
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const handleMoveEnd = useCallback((event: any) => {
|
const handleMoveEnd = useCallback(
|
||||||
|
(event: any) => {
|
||||||
if (onMoveEnd) {
|
if (onMoveEnd) {
|
||||||
onMoveEnd(event.viewState);
|
onMoveEnd(event.viewState)
|
||||||
}
|
}
|
||||||
}, [onMoveEnd]);
|
},
|
||||||
|
[onMoveEnd],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleControlChange = (control: string) => {
|
||||||
|
setActiveControl(control)
|
||||||
|
// Here you would implement logic to change the map display based on the selected control
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeChange = (time: string) => {
|
||||||
|
setActiveTime(time)
|
||||||
|
// Here you would implement logic to change the time period of data shown
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSidebar = () => {
|
||||||
|
setSidebarOpen(!sidebarOpen)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`}>
|
<div ref={mapContainerRef} className={`relative ${className}`}>
|
||||||
<div className="absolute inset-0 z-10 pointer-events-none" />
|
{/* Custom controls - only show when in fullscreen mode */}
|
||||||
<ReactMapGL
|
{isFullscreen && (
|
||||||
ref={ref => setMapRef(ref)}
|
<>
|
||||||
mapStyle={mapStyle}
|
{/* Sidebar */}
|
||||||
mapboxAccessToken={mapboxApiAccessToken}
|
<MapSidebar
|
||||||
initialViewState={defaultViewState}
|
isOpen={sidebarOpen}
|
||||||
onLoad={handleMapLoad}
|
onToggle={toggleSidebar}
|
||||||
onMoveEnd={handleMoveEnd}
|
crimes={crimes}
|
||||||
interactiveLayerIds={['district-fill']}
|
selectedYear={selectedYear}
|
||||||
attributionControl={false}
|
selectedMonth={selectedMonth}
|
||||||
>
|
/>
|
||||||
{children}
|
<SidebarToggle isOpen={sidebarOpen} onToggle={toggleSidebar} />
|
||||||
<NavigationControl position="top-right" />
|
|
||||||
<FullscreenControl position="top-right" />
|
|
||||||
<ScaleControl position="bottom-right" />
|
|
||||||
</ReactMapGL>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
);
|
{/* Additional controls that should only appear in fullscreen */}
|
||||||
|
<div className="absolute top-2 right-2 z-10 flex items-center bg-white rounded-md shadow-md">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search location..."
|
||||||
|
className="px-3 py-2 rounded-l-md border-0 focus:outline-none w-64"
|
||||||
|
/>
|
||||||
|
<button className="bg-gray-100 p-2 rounded-r-md">
|
||||||
|
<Search size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapControls onControlChange={handleControlChange} activeControl={activeControl} />
|
||||||
|
<TimeControls onTimeChange={handleTimeChange} activeTime={activeTime} />
|
||||||
|
<SeverityIndicator />
|
||||||
|
|
||||||
|
{/* Make sure customControls is displayed in fullscreen mode */}
|
||||||
|
{customControls}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main content with left padding when sidebar is open */}
|
||||||
|
<div className={`relative h-full transition-all duration-300 ${isFullscreen && sidebarOpen ? "ml-80" : "ml-0"}`}>
|
||||||
|
<ReactMapGL
|
||||||
|
ref={(ref) => setMapRef(ref)}
|
||||||
|
mapStyle={mapStyle}
|
||||||
|
mapboxAccessToken={mapboxApiAccessToken}
|
||||||
|
initialViewState={defaultViewState}
|
||||||
|
onLoad={handleMapLoad}
|
||||||
|
onMoveEnd={handleMoveEnd}
|
||||||
|
interactiveLayerIds={["district-fill", "clusters", "unclustered-point"]}
|
||||||
|
attributionControl={false}
|
||||||
|
style={{ width, height }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<NavigationControl position="right" />
|
||||||
|
<FullscreenControl
|
||||||
|
position="right"
|
||||||
|
containerId={mapContainerRef.current?.id}
|
||||||
|
/>
|
||||||
|
<ScaleControl position="bottom-left" />
|
||||||
|
|
||||||
|
{/* GeolocateControl only shown in fullscreen mode */}
|
||||||
|
{isFullscreen && (
|
||||||
|
<GeolocateControl position="right" />
|
||||||
|
)}
|
||||||
|
</ReactMapGL>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Debug indicator - remove in production */}
|
||||||
|
<div className="absolute bottom-4 left-4 bg-black bg-opacity-70 text-white text-xs p-1 rounded z-50">
|
||||||
|
Fullscreen: {isFullscreen ? "Yes" : "No"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
|
@ -22,7 +22,6 @@ type CrimeMarkerProps = {
|
||||||
|
|
||||||
|
|
||||||
export default function CrimeMarker({ incident, onClick }: CrimeMarkerProps) {
|
export default function CrimeMarker({ incident, onClick }: CrimeMarkerProps) {
|
||||||
console.log("CrimeMarker", incident)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, RefObject } from 'react';
|
||||||
|
|
||||||
|
export function useFullscreen(ref: RefObject<HTMLElement | null>) {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
|
||||||
|
const element = ref.current;
|
||||||
|
|
||||||
|
const handleFullscreenChange = () => {
|
||||||
|
const fullscreenElement =
|
||||||
|
document.fullscreenElement ||
|
||||||
|
(document as any).webkitFullscreenElement ||
|
||||||
|
(document as any).mozFullScreenElement ||
|
||||||
|
(document as any).msFullscreenElement;
|
||||||
|
|
||||||
|
setIsFullscreen(
|
||||||
|
fullscreenElement === element ||
|
||||||
|
(fullscreenElement && element.contains(fullscreenElement))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add event listeners for fullscreen changes
|
||||||
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
|
||||||
|
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
|
||||||
|
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
|
||||||
|
|
||||||
|
// Clean up event listeners
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||||
|
document.removeEventListener(
|
||||||
|
'webkitfullscreenchange',
|
||||||
|
handleFullscreenChange
|
||||||
|
);
|
||||||
|
document.removeEventListener(
|
||||||
|
'mozfullscreenchange',
|
||||||
|
handleFullscreenChange
|
||||||
|
);
|
||||||
|
document.removeEventListener(
|
||||||
|
'MSFullscreenChange',
|
||||||
|
handleFullscreenChange
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [ref]);
|
||||||
|
|
||||||
|
// Function to request fullscreen
|
||||||
|
const enterFullscreen = () => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
|
||||||
|
const element = ref.current;
|
||||||
|
|
||||||
|
if (element.requestFullscreen) {
|
||||||
|
element.requestFullscreen();
|
||||||
|
} else if ((element as any).webkitRequestFullscreen) {
|
||||||
|
(element as any).webkitRequestFullscreen();
|
||||||
|
} else if ((element as any).mozRequestFullScreen) {
|
||||||
|
(element as any).mozRequestFullScreen();
|
||||||
|
} else if ((element as any).msRequestFullscreen) {
|
||||||
|
(element as any).msRequestFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to exit fullscreen
|
||||||
|
const exitFullscreen = () => {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
|
document.exitFullscreen();
|
||||||
|
} else if ((document as any).webkitExitFullscreen) {
|
||||||
|
(document as any).webkitExitFullscreen();
|
||||||
|
} else if ((document as any).mozCancelFullScreen) {
|
||||||
|
(document as any).mozCancelFullScreen();
|
||||||
|
} else if ((document as any).msExitFullscreen) {
|
||||||
|
(document as any).msExitFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
if (isFullscreen) {
|
||||||
|
exitFullscreen();
|
||||||
|
} else {
|
||||||
|
enterFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { isFullscreen, enterFullscreen, exitFullscreen, toggleFullscreen };
|
||||||
|
}
|
|
@ -27,14 +27,14 @@
|
||||||
"incidents": [
|
"incidents": [
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2243-2024",
|
"id": "CI-3509-2243-2024",
|
||||||
"timestamp": "2024-01-17T02:43:00.000Z",
|
"timestamp": "2024-01-19T09:48:00.000Z",
|
||||||
"description": "Laporan kenakalan remaja terjadi pada Wed Jan 17 2024 09:43:00 GMT+0700 (Western Indonesia Time) di jalan utama Jombang",
|
"description": "Kasus penyelenggaraan pemilu terjadi di Jalan Gajah Mada",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Kenakalan Remaja",
|
"category": "Penyelenggaraan Pemilu",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Gajah Mada Blok H-7, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.220942806386573,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3642056086129
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -65,14 +65,14 @@
|
||||||
"incidents": [
|
"incidents": [
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2244-2024",
|
"id": "CI-3509-2244-2024",
|
||||||
"timestamp": "2024-02-26T16:31:00.000Z",
|
"timestamp": "2024-02-03T19:37:00.000Z",
|
||||||
"description": "Kejadian perlindungan anak di perbatasan Jombang",
|
"description": "Sistem Peradilan Anak terdeteksi di sekitar Jombang pada 2:37:00 AM",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Perlindungan Anak",
|
"category": "Sistem Peradilan Anak",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Alun-alun Jombang, Jalan Jawa, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.225862928612479,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3545810955381
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -103,47 +103,47 @@
|
||||||
"incidents": [
|
"incidents": [
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2245-2024",
|
"id": "CI-3509-2245-2024",
|
||||||
"timestamp": "2024-03-06T19:08:00.000Z",
|
"timestamp": "2024-03-16T23:04:00.000Z",
|
||||||
"description": "Insiden perlindungan konsumen terjadi di perbatasan Jombang",
|
"description": "Kejadian fidusia di sekitar Jombang",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Perlindungan Konsumen",
|
"category": "Fidusia",
|
||||||
"type": "Pidana Tertentu",
|
"type": "Pidana Tertentu",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Diponegoro No. 19, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.21880601996157,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3583240255355
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2246-2024",
|
"id": "CI-3509-2246-2024",
|
||||||
"timestamp": "2024-03-21T04:28:00.000Z",
|
"timestamp": "2024-03-09T01:45:00.000Z",
|
||||||
"description": "Laporan terhadap ketertiban umum terjadi pada Thu Mar 21 2024 11:28:00 GMT+0700 (Western Indonesia Time) di jalan utama Jombang",
|
"description": "Pelaporan keimigrasian di Jalan Srikandi Blok B-15, Jombang, Jember",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Terhadap Ketertiban Umum",
|
"category": "Keimigrasian",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Srikandi Blok B-15, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.218269599612134,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3537943056248
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2247-2024",
|
"id": "CI-3509-2247-2024",
|
||||||
"timestamp": "2024-03-14T23:13:00.000Z",
|
"timestamp": "2024-03-08T02:47:00.000Z",
|
||||||
"description": "Insiden selundup senpi terjadi di jalan utama Jombang",
|
"description": "Membahayakan Kam Umum terjadi di dekat pertigaan Jombang",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Selundup Senpi",
|
"category": "Membahayakan Kam Umum",
|
||||||
"type": "Pidana Tertentu",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Kalimantan No. 58, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.218269599612134,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3537943056248
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2248-2024",
|
"id": "CI-3509-2248-2024",
|
||||||
"timestamp": "2024-03-03T16:54:00.000Z",
|
"timestamp": "2024-03-20T19:06:00.000Z",
|
||||||
"description": "Insiden ekstradisi terjadi di wilayah Jombang",
|
"description": "Kasus penggelapan Jalan Cendrawasih Blok O-1, Jombang, Jember",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Ekstradisi",
|
"category": "Penggelapan",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Cendrawasih Blok O-1, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.226009836151528,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3552530903767
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -174,25 +174,25 @@
|
||||||
"incidents": [
|
"incidents": [
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2249-2024",
|
"id": "CI-3509-2249-2024",
|
||||||
"timestamp": "2024-04-03T12:14:00.000Z",
|
"timestamp": "2024-04-14T09:23:00.000Z",
|
||||||
"description": "Insiden agraria terjadi di pasar Jombang",
|
"description": "Curat terdeteksi di area Jalan Cendrawasih pada 4:23:00 PM",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Agraria",
|
"category": "Curat",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Cendrawasih Blok P-9, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.219532604872622,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3658183785155
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2250-2024",
|
"id": "CI-3509-2250-2024",
|
||||||
"timestamp": "2024-04-22T10:17:00.000Z",
|
"timestamp": "2024-04-03T10:56:00.000Z",
|
||||||
"description": "Curanmor dilaporkan di daerah Jombang",
|
"description": "Pidter Lainnya terdeteksi di perumahan Jombang pada 5:56:00 PM",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Curanmor",
|
"category": "Pidter Lainnya",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Tertentu",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Sekolah Jombang, Jalan Cendrawasih, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.217365999685471,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.364745657837
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -223,25 +223,25 @@
|
||||||
"incidents": [
|
"incidents": [
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2251-2024",
|
"id": "CI-3509-2251-2024",
|
||||||
"timestamp": "2024-05-30T03:01:00.000Z",
|
"timestamp": "2024-05-29T19:25:00.000Z",
|
||||||
"description": "Kasus trans ekonomi crime Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"description": "Kasus perlindungan saksi – korban terjadi di Jalan Letjen Suprapto",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Trans Ekonomi Crime",
|
"category": "Perlindungan Saksi – Korban",
|
||||||
"type": "Pidana Tertentu",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Letjen Suprapto Blok G-13, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.220640105831363,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3593557708753
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2252-2024",
|
"id": "CI-3509-2252-2024",
|
||||||
"timestamp": "2024-05-24T01:40:00.000Z",
|
"timestamp": "2024-05-06T09:30:00.000Z",
|
||||||
"description": "Insiden terhadap ketertiban umum terjadi di daerah Jombang",
|
"description": "Kejadian penghinaan di kawasan pertokoan Jombang",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Terhadap Ketertiban Umum",
|
"category": "Penghinaan",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Pertokoan Jombang, Jalan Srikandi, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.223170621517482,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3596837062433
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -272,14 +272,14 @@
|
||||||
"incidents": [
|
"incidents": [
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2253-2024",
|
"id": "CI-3509-2253-2024",
|
||||||
"timestamp": "2024-06-04T12:32:00.000Z",
|
"timestamp": "2024-06-28T15:18:00.000Z",
|
||||||
"description": "Insiden pengrusakan terjadi di perbatasan Jombang",
|
"description": "Laporan trafficking in person terjadi pada Fri Jun 28 2024 22:18:00 GMT+0700 (Western Indonesia Time) di perbatasan Jombang",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Pengrusakan",
|
"category": "Trafficking In Person",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Tertentu",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Komplek Jombang, Jalan Pantai, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.225037955873907,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.356324271068
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -310,25 +310,25 @@
|
||||||
"incidents": [
|
"incidents": [
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2254-2024",
|
"id": "CI-3509-2254-2024",
|
||||||
"timestamp": "2024-07-01T18:32:00.000Z",
|
"timestamp": "2024-07-13T09:33:00.000Z",
|
||||||
"description": "Penadahan dilaporkan di jalan utama Jombang",
|
"description": "Laporan trans ekonomi crime terjadi pada Sat Jul 13 2024 16:33:00 GMT+0700 (Western Indonesia Time) di persimpangan jalan Jalan Raya Sumberbaru",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Penadahan",
|
"category": "Trans Ekonomi Crime",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Tertentu",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Raya Sumberbaru Blok E-3, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.218091578513922,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3615109511363
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2255-2024",
|
"id": "CI-3509-2255-2024",
|
||||||
"timestamp": "2024-07-09T17:49:00.000Z",
|
"timestamp": "2024-07-02T23:46:00.000Z",
|
||||||
"description": "Satwa dilaporkan di daerah Jombang",
|
"description": "Pelaporan penganiayaan ringan di Jalan Jawa No. 13, Jombang, Jember",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Satwa",
|
"category": "Penganiayaan Ringan",
|
||||||
"type": "Pidana Tertentu",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Jawa No. 13, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.227294841217901,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3512174459733
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -359,36 +359,36 @@
|
||||||
"incidents": [
|
"incidents": [
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2256-2024",
|
"id": "CI-3509-2256-2024",
|
||||||
"timestamp": "2024-08-25T09:25:00.000Z",
|
"timestamp": "2024-08-08T20:58:00.000Z",
|
||||||
"description": "Kasus penyelenggaraan pemilu Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"description": "Pelaporan fidusia di Toko Jombang, Jalan Cendrawasih, Jember",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Penyelenggaraan Pemilu",
|
"category": "Fidusia",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Tertentu",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Toko Jombang, Jalan Cendrawasih, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.221020024793528,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3600760414435
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2257-2024",
|
"id": "CI-3509-2257-2024",
|
||||||
"timestamp": "2024-08-30T03:36:00.000Z",
|
"timestamp": "2024-08-01T05:39:00.000Z",
|
||||||
"description": "Laporan pekerjakan anak terjadi pada Fri Aug 30 2024 10:36:00 GMT+0700 (Western Indonesia Time) di pasar Jombang",
|
"description": "Insiden pengrusakan terjadi di pasar Jombang",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Pekerjakan Anak",
|
"category": "Pengrusakan",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Toko Jombang, Jalan Srikandi, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.220330780964309,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3575533505413
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2258-2024",
|
"id": "CI-3509-2258-2024",
|
||||||
"timestamp": "2024-08-21T13:52:00.000Z",
|
"timestamp": "2024-08-24T03:35:00.000Z",
|
||||||
"description": "Kasus menerima suap Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"description": "Laporan trafficking in person terjadi pada Sat Aug 24 2024 10:35:00 GMT+0700 (Western Indonesia Time) di wilayah Jombang",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Menerima Suap",
|
"category": "Trafficking In Person",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Tertentu",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Alun-alun Jombang, Jalan Jawa, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.225862928612479,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3545810955381
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -419,25 +419,25 @@
|
||||||
"incidents": [
|
"incidents": [
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2259-2024",
|
"id": "CI-3509-2259-2024",
|
||||||
"timestamp": "2024-09-12T22:50:00.000Z",
|
"timestamp": "2024-09-01T12:23:00.000Z",
|
||||||
"description": "Kejadian korupsi di pasar Jombang",
|
"description": "Kasus premanisme terjadi di Jalan Cendrawasih",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Korupsi",
|
"category": "Premanisme",
|
||||||
"type": "Korupsi",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Cendrawasih No. 57, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.223364642360638,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3587958397817
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2260-2024",
|
"id": "CI-3509-2260-2024",
|
||||||
"timestamp": "2024-09-01T17:56:00.000Z",
|
"timestamp": "2024-09-05T14:40:00.000Z",
|
||||||
"description": "Laporan pemalsuan surat terjadi pada Mon Sep 02 2024 00:56:00 GMT+0700 (Western Indonesia Time) di daerah Jombang",
|
"description": "Kejadian sistem peradilan anak di jalan utama Jombang",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Pemalsuan Surat",
|
"category": "Sistem Peradilan Anak",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Mastrip Blok E-7, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.214910387946766,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3617351868134
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
@ -468,38 +468,63 @@
|
||||||
"incidents": [
|
"incidents": [
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2261-2024",
|
"id": "CI-3509-2261-2024",
|
||||||
"timestamp": "2024-10-26T02:32:00.000Z",
|
"timestamp": "2024-10-11T14:44:00.000Z",
|
||||||
"description": "Laporan menerima suap terjadi pada Sat Oct 26 2024 09:32:00 GMT+0700 (Western Indonesia Time) di pasar Jombang",
|
"description": "Insiden lahgun senpi/handak/sajam dilaporkan warga setempat di jalan utama Jombang",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Menerima Suap",
|
"category": "Lahgun Senpi/Handak/Sajam",
|
||||||
"type": "Pidana Umum",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Pantai No. 92, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.222114170247359,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3585291112248
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2262-2024",
|
"id": "CI-3509-2262-2024",
|
||||||
"timestamp": "2024-10-25T14:48:00.000Z",
|
"timestamp": "2024-10-11T20:10:00.000Z",
|
||||||
"description": "Trafficking In Person dilaporkan di wilayah Jombang",
|
"description": "Insiden perlindungan konsumen terjadi di belakang sekolah Jombang",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Trafficking In Person",
|
"category": "Perlindungan Konsumen",
|
||||||
"type": "Pidana Tertentu",
|
"type": "Pidana Tertentu",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Sekolah Jombang, Jalan Jawa, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.229801790715758,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3609559436243
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "CI-3509-2263-2024",
|
"id": "CI-3509-2263-2024",
|
||||||
"timestamp": "2024-10-31T12:47:00.000Z",
|
"timestamp": "2024-10-22T21:08:00.000Z",
|
||||||
"description": "Kasus money loudering Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"description": "Perlindungan Anak terdeteksi di belakang perempatan Jombang pada 4:08:00 AM",
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"category": "Money Loudering",
|
"category": "Perlindungan Anak",
|
||||||
"type": "Pidana Tertentu",
|
"type": "Pidana Umum",
|
||||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
"address": "Jalan Srikandi No. 64, Jombang, Jember",
|
||||||
"latitude": -8.207667404400098,
|
"latitude": -8.22081341311974,
|
||||||
"longitude": 113.3497229500003
|
"longitude": 113.3589319157199
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"meta": {
|
||||||
|
"values": {
|
||||||
|
"0.incidents.0.timestamp": ["Date"],
|
||||||
|
"1.incidents.0.timestamp": ["Date"],
|
||||||
|
"2.incidents.0.timestamp": ["Date"],
|
||||||
|
"2.incidents.1.timestamp": ["Date"],
|
||||||
|
"2.incidents.2.timestamp": ["Date"],
|
||||||
|
"2.incidents.3.timestamp": ["Date"],
|
||||||
|
"3.incidents.0.timestamp": ["Date"],
|
||||||
|
"3.incidents.1.timestamp": ["Date"],
|
||||||
|
"4.incidents.0.timestamp": ["Date"],
|
||||||
|
"4.incidents.1.timestamp": ["Date"],
|
||||||
|
"5.incidents.0.timestamp": ["Date"],
|
||||||
|
"6.incidents.0.timestamp": ["Date"],
|
||||||
|
"6.incidents.1.timestamp": ["Date"],
|
||||||
|
"7.incidents.0.timestamp": ["Date"],
|
||||||
|
"7.incidents.1.timestamp": ["Date"],
|
||||||
|
"7.incidents.2.timestamp": ["Date"],
|
||||||
|
"8.incidents.0.timestamp": ["Date"],
|
||||||
|
"8.incidents.1.timestamp": ["Date"],
|
||||||
|
"9.incidents.0.timestamp": ["Date"],
|
||||||
|
"9.incidents.1.timestamp": ["Date"],
|
||||||
|
"9.incidents.2.timestamp": ["Date"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue