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) => {
|
||||
|
|
|
@ -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() {
|
||||
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="space-y-1 mb-3">
|
||||
<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 CrimeMarker, { type CrimeIncident } from "./markers/crime-marker"
|
||||
import { MapLegend } from "./controls/map-legend"
|
||||
import MapFilterControl from "./controls/map-filter-control"
|
||||
|
||||
const months = [
|
||||
{ value: "1", label: "January" },
|
||||
|
@ -36,6 +37,7 @@ export default function CrimeMap() {
|
|||
const [selectedMonth, setSelectedMonth] = useState<number | "all">("all")
|
||||
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
||||
const [selectedIncident, setSelectedIncident] = useState<CrimeIncident | null>(null)
|
||||
const [showLegend, setShowLegend] = useState<boolean>(true)
|
||||
|
||||
const { availableYears, yearsLoading, yearsError, crimes, crimesLoading, crimesError, refetchCrimes } =
|
||||
useCrimeMapHandler(selectedYear, selectedMonth)
|
||||
|
@ -83,26 +85,40 @@ export default function CrimeMap() {
|
|||
let title = `${selectedYear}`
|
||||
if (selectedMonth !== "all") {
|
||||
title += ` - ${getMonthName(Number(selectedMonth))}`
|
||||
}
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// Create map filter controls - now MapView will only render these in fullscreen mode
|
||||
const mapFilterControls = (
|
||||
<MapFilterControl
|
||||
selectedYear={selectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
availableYears={availableYears || []}
|
||||
yearsLoading={yearsLoading}
|
||||
onYearChange={setSelectedYear}
|
||||
onMonthChange={setSelectedMonth}
|
||||
onApplyFilters={applyFilters}
|
||||
onResetFilters={resetFilters}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<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>
|
||||
{/* Removed "All Years" option */}
|
||||
{!yearsLoading &&
|
||||
availableYears
|
||||
?.filter((year) => year !== null)
|
||||
.map((year) => (
|
||||
<SelectItem key={year} value={year.toString()}>
|
||||
<SelectItem key={year} value={year!.toString()}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
@ -133,6 +149,9 @@ export default function CrimeMap() {
|
|||
<FilterX className="h-4 w-4 mr-2" />
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowLegend(!showLegend)}>
|
||||
{showLegend ? "Hide Legend" : "Show Legend"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
|
@ -147,10 +166,17 @@ export default function CrimeMap() {
|
|||
<Button onClick={() => refetchCrimes()}>Retry</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative h-96">
|
||||
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-96 w-full rounded-md">
|
||||
{/* Display the legend */}
|
||||
{/* <MapLegend /> */}
|
||||
<div className="relative h-[600px]">
|
||||
<MapView
|
||||
mapStyle="mapbox://styles/mapbox/dark-v11"
|
||||
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 */}
|
||||
<DistrictLayer
|
||||
|
@ -161,9 +187,9 @@ export default function CrimeMap() {
|
|||
/>
|
||||
|
||||
{/* Display all crime incident markers */}
|
||||
{allIncidents?.map((incident) => (
|
||||
<CrimeMarker key={incident.id} incident={incident} onClick={handleIncidentClick} />
|
||||
))}
|
||||
{/* {allIncidents?.map((incident) => (
|
||||
<CrimeMarker key={incident.id} incident={incident} onClick={handleIncidentClick} />
|
||||
))} */}
|
||||
|
||||
{/* Popup for selected incident */}
|
||||
{selectedIncident && (
|
||||
|
|
|
@ -1,36 +1,36 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useMap } from 'react-map-gl/mapbox';
|
||||
import { CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from '@/app/_utils/const/map';
|
||||
import { DistrictPopup } from '../pop-up';
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import { useMap } from "react-map-gl/mapbox"
|
||||
import { CRIME_RATE_COLORS, MAPBOX_TILESET_ID } from "@/app/_utils/const/map"
|
||||
import { DistrictPopup } from "../pop-up"
|
||||
|
||||
// Types for district properties
|
||||
export interface DistrictFeature {
|
||||
id: string;
|
||||
name: string;
|
||||
properties: Record<string, any>;
|
||||
longitude?: number;
|
||||
latitude?: number;
|
||||
number_of_crime?: number;
|
||||
level?: 'low' | 'medium' | 'high' | 'critical';
|
||||
id: string
|
||||
name: string
|
||||
properties: Record<string, any>
|
||||
longitude?: number
|
||||
latitude?: number
|
||||
number_of_crime?: number
|
||||
level?: "low" | "medium" | "high" | "critical"
|
||||
}
|
||||
|
||||
// District layer props
|
||||
export interface DistrictLayerProps {
|
||||
visible?: boolean;
|
||||
onClick?: (feature: DistrictFeature) => void;
|
||||
year?: string;
|
||||
month?: string;
|
||||
visible?: boolean
|
||||
onClick?: (feature: DistrictFeature) => void
|
||||
year?: string
|
||||
month?: string
|
||||
crimes?: Array<{
|
||||
id: string;
|
||||
district_name: string;
|
||||
distrcit_id?: string;
|
||||
number_of_crime?: number;
|
||||
level?: 'low' | 'medium' | 'high' | 'critical';
|
||||
incidents: any[];
|
||||
}>;
|
||||
tilesetId?: string;
|
||||
id: string
|
||||
district_name: string
|
||||
distrcit_id?: string
|
||||
number_of_crime?: number
|
||||
level?: "low" | "medium" | "high" | "critical"
|
||||
incidents: any[]
|
||||
}>
|
||||
tilesetId?: string
|
||||
}
|
||||
|
||||
export default function DistrictLayer({
|
||||
|
@ -39,186 +39,489 @@ export default function DistrictLayer({
|
|||
year,
|
||||
month,
|
||||
crimes = [],
|
||||
tilesetId = MAPBOX_TILESET_ID
|
||||
tilesetId = MAPBOX_TILESET_ID,
|
||||
}: DistrictLayerProps) {
|
||||
const { current: map } = useMap();
|
||||
const { current: map } = useMap()
|
||||
|
||||
const [hoverInfo, setHoverInfo] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
feature: any;
|
||||
} | null>(null);
|
||||
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null);
|
||||
x: number
|
||||
y: number
|
||||
feature: any
|
||||
} | null>(null)
|
||||
const [selectedDistrict, setSelectedDistrict] = useState<DistrictFeature | null>(null)
|
||||
|
||||
// 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)
|
||||
const crimeDataByDistrict = crimes.reduce((acc, crime) => {
|
||||
// We'll use kode_kec as the key to match with tileset properties
|
||||
const districtId = crime.distrcit_id || crime.district_name;
|
||||
const crimeDataByDistrict = crimes.reduce(
|
||||
(acc, crime) => {
|
||||
// Use district_id (which corresponds to kode_kec in the tileset) as the key
|
||||
const districtId = crime.distrcit_id || crime.district_name
|
||||
|
||||
acc[districtId] = {
|
||||
number_of_crime: crime.number_of_crime,
|
||||
level: crime.level,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, { number_of_crime?: number; level?: 'low' | 'medium' | 'high' | 'critical' }>);
|
||||
console.log("Mapping district:", districtId, "level:", crime.level)
|
||||
|
||||
acc[districtId] = {
|
||||
number_of_crime: crime.number_of_crime,
|
||||
level: crime.level,
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, { number_of_crime?: number; level?: "low" | "medium" | "high" | "critical" }>,
|
||||
)
|
||||
|
||||
// Handle click on district
|
||||
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 districtId = feature.properties.kode_kec; // Using kode_kec as the unique identifier
|
||||
const crimeData = crimeDataByDistrict[districtId] || {};
|
||||
const feature = e.features[0]
|
||||
const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier
|
||||
const crimeData = crimeDataByDistrict[districtId] || {}
|
||||
|
||||
const district: DistrictFeature = {
|
||||
id: districtId,
|
||||
name: feature.properties.nama || feature.properties.kecamatan,
|
||||
properties: feature.properties,
|
||||
longitude: e.lngLat.lng,
|
||||
latitude: e.lngLat.lat,
|
||||
...crimeData,
|
||||
};
|
||||
const district: DistrictFeature = {
|
||||
id: districtId,
|
||||
name: feature.properties.nama || feature.properties.kecamatan,
|
||||
properties: feature.properties,
|
||||
longitude: e.lngLat.lng,
|
||||
latitude: e.lngLat.lat,
|
||||
...crimeData,
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
onClick(district);
|
||||
} else {
|
||||
setSelectedDistrict(district);
|
||||
}
|
||||
};
|
||||
if (onClick) {
|
||||
onClick(district)
|
||||
} else {
|
||||
setSelectedDistrict(district)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle mouse move for hover effect
|
||||
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 districtId = feature.properties.kode_kec; // Using kode_kec as the unique identifier
|
||||
const crimeData = crimeDataByDistrict[districtId] || {};
|
||||
const feature = e.features[0]
|
||||
const districtId = feature.properties.kode_kec // Using kode_kec as the unique identifier
|
||||
const crimeData = crimeDataByDistrict[districtId] || {}
|
||||
|
||||
// Enhance feature with crime data
|
||||
feature.properties = {
|
||||
...feature.properties,
|
||||
...crimeData,
|
||||
};
|
||||
console.log("Hover district:", districtId, "found data:", crimeData)
|
||||
|
||||
setHoverInfo({
|
||||
x: e.point.x,
|
||||
y: e.point.y,
|
||||
feature: feature,
|
||||
});
|
||||
};
|
||||
// Enhance feature with crime data
|
||||
feature.properties = {
|
||||
...feature.properties,
|
||||
...crimeData,
|
||||
}
|
||||
|
||||
setHoverInfo({
|
||||
x: e.point.x,
|
||||
y: e.point.y,
|
||||
feature: feature,
|
||||
})
|
||||
}
|
||||
|
||||
// Add district layer to the map when it's loaded
|
||||
useEffect(() => {
|
||||
if (!map || !visible || layersAdded.current) return;
|
||||
if (!map || !visible) return
|
||||
|
||||
// Handler for style load event
|
||||
const onStyleLoad = () => {
|
||||
// Skip if layers are already added or map is not available
|
||||
if (layersAdded.current || !map) return;
|
||||
// Handler for style load event
|
||||
const onStyleLoad = () => {
|
||||
// Skip if map is not available
|
||||
if (!map) return
|
||||
|
||||
try {
|
||||
// Add the vector tile source
|
||||
map.getMap().addSource('districts', {
|
||||
type: 'vector',
|
||||
url: `mapbox://${tilesetId}`
|
||||
});
|
||||
try {
|
||||
// Check if the source already exists to prevent duplicates
|
||||
if (!map.getMap().getSource("districts")) {
|
||||
// Get the first symbol layer ID from the map style
|
||||
// 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
|
||||
map.getMap().addLayer({
|
||||
id: 'district-fill',
|
||||
type: 'fill',
|
||||
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,
|
||||
}
|
||||
});
|
||||
// Add the vector tile source
|
||||
map.getMap().addSource("districts", {
|
||||
type: "vector",
|
||||
url: `mapbox://${tilesetId}`,
|
||||
})
|
||||
|
||||
// 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
|
||||
map.getMap().addLayer({
|
||||
id: 'district-line',
|
||||
type: 'line',
|
||||
source: 'districts',
|
||||
'source-layer': 'Districts',
|
||||
paint: {
|
||||
'line-color': '#ffffff',
|
||||
'line-width': 1,
|
||||
'line-opacity': 0.5,
|
||||
}
|
||||
});
|
||||
|
||||
// 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');
|
||||
} catch (error) {
|
||||
console.error('Error adding district layers:', error);
|
||||
map.getMap().addLayer(
|
||||
{
|
||||
id: "district-line",
|
||||
type: "line",
|
||||
source: "districts",
|
||||
"source-layer": "Districts",
|
||||
paint: {
|
||||
"line-color": "#ffffff",
|
||||
"line-width": 1,
|
||||
"line-opacity": 0.5,
|
||||
},
|
||||
},
|
||||
firstSymbolId,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// 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 && layersAdded.current) {
|
||||
map.off('click', 'district-fill', handleClick);
|
||||
map.off('mousemove', 'district-fill', handleMouseMove);
|
||||
map.off('mouseleave', 'district-fill', () => setHoverInfo(null));
|
||||
|
||||
// If we want to remove the layers and source on component unmount:
|
||||
if (map.getLayer('district-line')) map.getMap().removeLayer('district-line');
|
||||
if (map.getLayer('district-fill')) map.getMap().removeLayer('district-fill');
|
||||
if (map.getSource('districts')) map.getMap().removeSource('districts');
|
||||
|
||||
layersAdded.current = false;
|
||||
if (!map.getMap().getLayer("district-labels")) {
|
||||
// Add district labels with improved visibility and responsive sizing
|
||||
map.getMap().addLayer(
|
||||
{
|
||||
id: "district-labels",
|
||||
type: "symbol",
|
||||
source: "districts",
|
||||
"source-layer": "Districts",
|
||||
layout: {
|
||||
"text-field": ["get", "nama"],
|
||||
"text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
|
||||
// Make text size responsive to zoom level
|
||||
"text-size": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
9,
|
||||
8, // At zoom level 9, size 8px
|
||||
12,
|
||||
12, // At zoom level 12, size 12px
|
||||
15,
|
||||
14, // At zoom level 15, size 14px
|
||||
],
|
||||
"text-allow-overlap": false,
|
||||
"text-ignore-placement": false,
|
||||
// Adjust text anchor based on zoom level
|
||||
"text-anchor": "center",
|
||||
"text-justify": "center",
|
||||
"text-max-width": 8,
|
||||
// Show labels only at certain zoom levels
|
||||
"text-optional": true,
|
||||
"symbol-sort-key": ["get", "kode_kec"], // Sort labels by district code
|
||||
"symbol-z-order": "source",
|
||||
},
|
||||
paint: {
|
||||
"text-color": "#000000",
|
||||
"text-halo-color": "#ffffff",
|
||||
"text-halo-width": 2,
|
||||
"text-halo-blur": 1,
|
||||
// Fade in text opacity based on zoom level
|
||||
"text-opacity": [
|
||||
"interpolate",
|
||||
["linear"],
|
||||
["zoom"],
|
||||
8,
|
||||
0, // Fully transparent at zoom level 8
|
||||
9,
|
||||
0.6, // 60% opacity at zoom level 9
|
||||
10,
|
||||
1.0, // Fully opaque at zoom level 10
|
||||
],
|
||||
},
|
||||
},
|
||||
firstSymbolId,
|
||||
)
|
||||
}
|
||||
};
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
if (!map || !layersAdded.current) return;
|
||||
if (!map || !layersAdded.current) return
|
||||
|
||||
// Update the district-fill layer with new crime data
|
||||
try {
|
||||
// We need to update the layer paint property to correctly apply colors
|
||||
map.getMap().setPaintProperty('district-fill', 'fill-color', [
|
||||
'match',
|
||||
['coalesce', ['get', 'level'], 'default'],
|
||||
'low', CRIME_RATE_COLORS.low,
|
||||
'medium', CRIME_RATE_COLORS.medium,
|
||||
'high', CRIME_RATE_COLORS.high,
|
||||
'critical', CRIME_RATE_COLORS.critical,
|
||||
CRIME_RATE_COLORS.default
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error('Error updating district layer:', error);
|
||||
console.log("Updating district colors with data:", crimeDataByDistrict)
|
||||
|
||||
// Update the district-fill layer with new crime data
|
||||
try {
|
||||
// Check if the layer exists before updating it
|
||||
if (map.getMap().getLayer("district-fill")) {
|
||||
// We need to update the layer paint property to correctly apply colors
|
||||
map.getMap().setPaintProperty("district-fill", "fill-color", [
|
||||
"case",
|
||||
["has", "kode_kec"],
|
||||
[
|
||||
"match",
|
||||
["get", "kode_kec"],
|
||||
...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 (
|
||||
<>
|
||||
|
@ -238,26 +541,24 @@ export default function DistrictLayer({
|
|||
<p className="text-xs text-gray-600">
|
||||
{hoverInfo.feature.properties.number_of_crime} incidents
|
||||
{hoverInfo.feature.properties.level && (
|
||||
<span className="ml-2 text-xs font-semibold text-gray-500">
|
||||
({hoverInfo.feature.properties.level})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="ml-2 text-xs font-semibold text-gray-500">({hoverInfo.feature.properties.level})</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* District popup */}
|
||||
{selectedDistrict && selectedDistrict.longitude && selectedDistrict.latitude && (
|
||||
<DistrictPopup
|
||||
longitude={selectedDistrict.longitude}
|
||||
latitude={selectedDistrict.latitude}
|
||||
onClose={() => setSelectedDistrict(null)}
|
||||
district={selectedDistrict}
|
||||
year={year}
|
||||
month={month}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{/* District popup */}
|
||||
{selectedDistrict && selectedDistrict.longitude && selectedDistrict.latitude && (
|
||||
<DistrictPopup
|
||||
longitude={selectedDistrict.longitude}
|
||||
latitude={selectedDistrict.latitude}
|
||||
onClose={() => setSelectedDistrict(null)}
|
||||
district={selectedDistrict}
|
||||
year={year}
|
||||
month={month}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -1,71 +1,174 @@
|
|||
'use client';
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } 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 'mapbox-gl/dist/mapbox-gl.css';
|
||||
import type React from "react"
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
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 {
|
||||
children?: React.ReactNode;
|
||||
initialViewState?: Partial<ViewState>;
|
||||
mapStyle?: string;
|
||||
className?: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
mapboxApiAccessToken?: string;
|
||||
onMoveEnd?: (viewState: ViewState) => void;
|
||||
children?: React.ReactNode
|
||||
initialViewState?: Partial<ViewState>
|
||||
mapStyle?: string
|
||||
className?: string
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
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
|
||||
}
|
||||
|
||||
export default function MapView({
|
||||
children,
|
||||
initialViewState,
|
||||
mapStyle = MAP_STYLE,
|
||||
className = 'w-full h-96',
|
||||
width = '100%',
|
||||
height = '100%',
|
||||
className = "w-full h-96",
|
||||
width = "100%",
|
||||
height = "100%",
|
||||
mapboxApiAccessToken = process.env.NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN,
|
||||
onMoveEnd
|
||||
onMoveEnd,
|
||||
customControls,
|
||||
crimes = [],
|
||||
selectedYear,
|
||||
selectedMonth,
|
||||
}: 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> = {
|
||||
longitude: BASE_LONGITUDE, // Center of Jember region
|
||||
longitude: BASE_LONGITUDE,
|
||||
latitude: BASE_LATITUDE,
|
||||
zoom: BASE_ZOOM,
|
||||
bearing: 0,
|
||||
pitch: 0,
|
||||
...initialViewState
|
||||
};
|
||||
...initialViewState,
|
||||
}
|
||||
|
||||
const handleMapLoad = useCallback((event: any) => {
|
||||
setMapRef(event.target);
|
||||
}, []);
|
||||
setMapRef(event.target)
|
||||
}, [])
|
||||
|
||||
const handleMoveEnd = useCallback((event: any) => {
|
||||
const handleMoveEnd = useCallback(
|
||||
(event: any) => {
|
||||
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 (
|
||||
<div className={`relative ${className}`}>
|
||||
<div className="absolute inset-0 z-10 pointer-events-none" />
|
||||
<ReactMapGL
|
||||
ref={ref => setMapRef(ref)}
|
||||
mapStyle={mapStyle}
|
||||
mapboxAccessToken={mapboxApiAccessToken}
|
||||
initialViewState={defaultViewState}
|
||||
onLoad={handleMapLoad}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
interactiveLayerIds={['district-fill']}
|
||||
attributionControl={false}
|
||||
>
|
||||
{children}
|
||||
<NavigationControl position="top-right" />
|
||||
<FullscreenControl position="top-right" />
|
||||
<ScaleControl position="bottom-right" />
|
||||
</ReactMapGL>
|
||||
</div>
|
||||
<div ref={mapContainerRef} className={`relative ${className}`}>
|
||||
{/* Custom controls - only show when in fullscreen mode */}
|
||||
{isFullscreen && (
|
||||
<>
|
||||
{/* Sidebar */}
|
||||
<MapSidebar
|
||||
isOpen={sidebarOpen}
|
||||
onToggle={toggleSidebar}
|
||||
crimes={crimes}
|
||||
selectedYear={selectedYear}
|
||||
selectedMonth={selectedMonth}
|
||||
/>
|
||||
<SidebarToggle isOpen={sidebarOpen} onToggle={toggleSidebar} />
|
||||
|
||||
);
|
||||
}
|
||||
{/* Additional controls that should only appear in fullscreen */}
|
||||
<div className="absolute top-2 right-2 z-10 flex items-center bg-white rounded-md shadow-md">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search location..."
|
||||
className="px-3 py-2 rounded-l-md border-0 focus:outline-none w-64"
|
||||
/>
|
||||
<button className="bg-gray-100 p-2 rounded-r-md">
|
||||
<Search size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<MapControls onControlChange={handleControlChange} activeControl={activeControl} />
|
||||
<TimeControls onTimeChange={handleTimeChange} activeTime={activeTime} />
|
||||
<SeverityIndicator />
|
||||
|
||||
{/* Make sure customControls is displayed in fullscreen mode */}
|
||||
{customControls}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Main content with left padding when sidebar is open */}
|
||||
<div className={`relative h-full transition-all duration-300 ${isFullscreen && sidebarOpen ? "ml-80" : "ml-0"}`}>
|
||||
<ReactMapGL
|
||||
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) {
|
||||
console.log("CrimeMarker", incident)
|
||||
|
||||
return (
|
||||
<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": [
|
||||
{
|
||||
"id": "CI-3509-2243-2024",
|
||||
"timestamp": "2024-01-17T02:43:00.000Z",
|
||||
"description": "Laporan kenakalan remaja terjadi pada Wed Jan 17 2024 09:43:00 GMT+0700 (Western Indonesia Time) di jalan utama Jombang",
|
||||
"timestamp": "2024-01-19T09:48:00.000Z",
|
||||
"description": "Kasus penyelenggaraan pemilu terjadi di Jalan Gajah Mada",
|
||||
"status": "resolved",
|
||||
"category": "Kenakalan Remaja",
|
||||
"category": "Penyelenggaraan Pemilu",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"address": "Jalan Gajah Mada Blok H-7, Jombang, Jember",
|
||||
"latitude": -8.220942806386573,
|
||||
"longitude": 113.3642056086129
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -65,14 +65,14 @@
|
|||
"incidents": [
|
||||
{
|
||||
"id": "CI-3509-2244-2024",
|
||||
"timestamp": "2024-02-26T16:31:00.000Z",
|
||||
"description": "Kejadian perlindungan anak di perbatasan Jombang",
|
||||
"timestamp": "2024-02-03T19:37:00.000Z",
|
||||
"description": "Sistem Peradilan Anak terdeteksi di sekitar Jombang pada 2:37:00 AM",
|
||||
"status": "resolved",
|
||||
"category": "Perlindungan Anak",
|
||||
"category": "Sistem Peradilan Anak",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"address": "Alun-alun Jombang, Jalan Jawa, Jember",
|
||||
"latitude": -8.225862928612479,
|
||||
"longitude": 113.3545810955381
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -103,47 +103,47 @@
|
|||
"incidents": [
|
||||
{
|
||||
"id": "CI-3509-2245-2024",
|
||||
"timestamp": "2024-03-06T19:08:00.000Z",
|
||||
"description": "Insiden perlindungan konsumen terjadi di perbatasan Jombang",
|
||||
"timestamp": "2024-03-16T23:04:00.000Z",
|
||||
"description": "Kejadian fidusia di sekitar Jombang",
|
||||
"status": "resolved",
|
||||
"category": "Perlindungan Konsumen",
|
||||
"category": "Fidusia",
|
||||
"type": "Pidana Tertentu",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"address": "Jalan Diponegoro No. 19, Jombang, Jember",
|
||||
"latitude": -8.21880601996157,
|
||||
"longitude": 113.3583240255355
|
||||
},
|
||||
{
|
||||
"id": "CI-3509-2246-2024",
|
||||
"timestamp": "2024-03-21T04:28: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",
|
||||
"timestamp": "2024-03-09T01:45:00.000Z",
|
||||
"description": "Pelaporan keimigrasian di Jalan Srikandi Blok B-15, Jombang, Jember",
|
||||
"status": "resolved",
|
||||
"category": "Terhadap Ketertiban Umum",
|
||||
"category": "Keimigrasian",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"address": "Jalan Srikandi Blok B-15, Jombang, Jember",
|
||||
"latitude": -8.218269599612134,
|
||||
"longitude": 113.3537943056248
|
||||
},
|
||||
{
|
||||
"id": "CI-3509-2247-2024",
|
||||
"timestamp": "2024-03-14T23:13:00.000Z",
|
||||
"description": "Insiden selundup senpi terjadi di jalan utama Jombang",
|
||||
"timestamp": "2024-03-08T02:47:00.000Z",
|
||||
"description": "Membahayakan Kam Umum terjadi di dekat pertigaan Jombang",
|
||||
"status": "resolved",
|
||||
"category": "Selundup Senpi",
|
||||
"type": "Pidana Tertentu",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"category": "Membahayakan Kam Umum",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Kalimantan No. 58, Jombang, Jember",
|
||||
"latitude": -8.218269599612134,
|
||||
"longitude": 113.3537943056248
|
||||
},
|
||||
{
|
||||
"id": "CI-3509-2248-2024",
|
||||
"timestamp": "2024-03-03T16:54:00.000Z",
|
||||
"description": "Insiden ekstradisi terjadi di wilayah Jombang",
|
||||
"timestamp": "2024-03-20T19:06:00.000Z",
|
||||
"description": "Kasus penggelapan Jalan Cendrawasih Blok O-1, Jombang, Jember",
|
||||
"status": "resolved",
|
||||
"category": "Ekstradisi",
|
||||
"category": "Penggelapan",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"address": "Jalan Cendrawasih Blok O-1, Jombang, Jember",
|
||||
"latitude": -8.226009836151528,
|
||||
"longitude": 113.3552530903767
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -174,25 +174,25 @@
|
|||
"incidents": [
|
||||
{
|
||||
"id": "CI-3509-2249-2024",
|
||||
"timestamp": "2024-04-03T12:14:00.000Z",
|
||||
"description": "Insiden agraria terjadi di pasar Jombang",
|
||||
"timestamp": "2024-04-14T09:23:00.000Z",
|
||||
"description": "Curat terdeteksi di area Jalan Cendrawasih pada 4:23:00 PM",
|
||||
"status": "resolved",
|
||||
"category": "Agraria",
|
||||
"category": "Curat",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"address": "Jalan Cendrawasih Blok P-9, Jombang, Jember",
|
||||
"latitude": -8.219532604872622,
|
||||
"longitude": 113.3658183785155
|
||||
},
|
||||
{
|
||||
"id": "CI-3509-2250-2024",
|
||||
"timestamp": "2024-04-22T10:17:00.000Z",
|
||||
"description": "Curanmor dilaporkan di daerah Jombang",
|
||||
"timestamp": "2024-04-03T10:56:00.000Z",
|
||||
"description": "Pidter Lainnya terdeteksi di perumahan Jombang pada 5:56:00 PM",
|
||||
"status": "resolved",
|
||||
"category": "Curanmor",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"category": "Pidter Lainnya",
|
||||
"type": "Pidana Tertentu",
|
||||
"address": "Sekolah Jombang, Jalan Cendrawasih, Jember",
|
||||
"latitude": -8.217365999685471,
|
||||
"longitude": 113.364745657837
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -223,25 +223,25 @@
|
|||
"incidents": [
|
||||
{
|
||||
"id": "CI-3509-2251-2024",
|
||||
"timestamp": "2024-05-30T03:01:00.000Z",
|
||||
"description": "Kasus trans ekonomi crime Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"timestamp": "2024-05-29T19:25:00.000Z",
|
||||
"description": "Kasus perlindungan saksi – korban terjadi di Jalan Letjen Suprapto",
|
||||
"status": "resolved",
|
||||
"category": "Trans Ekonomi Crime",
|
||||
"type": "Pidana Tertentu",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"category": "Perlindungan Saksi – Korban",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Letjen Suprapto Blok G-13, Jombang, Jember",
|
||||
"latitude": -8.220640105831363,
|
||||
"longitude": 113.3593557708753
|
||||
},
|
||||
{
|
||||
"id": "CI-3509-2252-2024",
|
||||
"timestamp": "2024-05-24T01:40:00.000Z",
|
||||
"description": "Insiden terhadap ketertiban umum terjadi di daerah Jombang",
|
||||
"timestamp": "2024-05-06T09:30:00.000Z",
|
||||
"description": "Kejadian penghinaan di kawasan pertokoan Jombang",
|
||||
"status": "resolved",
|
||||
"category": "Terhadap Ketertiban Umum",
|
||||
"category": "Penghinaan",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"address": "Pertokoan Jombang, Jalan Srikandi, Jember",
|
||||
"latitude": -8.223170621517482,
|
||||
"longitude": 113.3596837062433
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -272,14 +272,14 @@
|
|||
"incidents": [
|
||||
{
|
||||
"id": "CI-3509-2253-2024",
|
||||
"timestamp": "2024-06-04T12:32:00.000Z",
|
||||
"description": "Insiden pengrusakan terjadi di perbatasan Jombang",
|
||||
"timestamp": "2024-06-28T15:18:00.000Z",
|
||||
"description": "Laporan trafficking in person terjadi pada Fri Jun 28 2024 22:18:00 GMT+0700 (Western Indonesia Time) di perbatasan Jombang",
|
||||
"status": "resolved",
|
||||
"category": "Pengrusakan",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"category": "Trafficking In Person",
|
||||
"type": "Pidana Tertentu",
|
||||
"address": "Komplek Jombang, Jalan Pantai, Jember",
|
||||
"latitude": -8.225037955873907,
|
||||
"longitude": 113.356324271068
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -310,25 +310,25 @@
|
|||
"incidents": [
|
||||
{
|
||||
"id": "CI-3509-2254-2024",
|
||||
"timestamp": "2024-07-01T18:32:00.000Z",
|
||||
"description": "Penadahan dilaporkan di jalan utama Jombang",
|
||||
"timestamp": "2024-07-13T09:33:00.000Z",
|
||||
"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",
|
||||
"category": "Penadahan",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"category": "Trans Ekonomi Crime",
|
||||
"type": "Pidana Tertentu",
|
||||
"address": "Jalan Raya Sumberbaru Blok E-3, Jombang, Jember",
|
||||
"latitude": -8.218091578513922,
|
||||
"longitude": 113.3615109511363
|
||||
},
|
||||
{
|
||||
"id": "CI-3509-2255-2024",
|
||||
"timestamp": "2024-07-09T17:49:00.000Z",
|
||||
"description": "Satwa dilaporkan di daerah Jombang",
|
||||
"timestamp": "2024-07-02T23:46:00.000Z",
|
||||
"description": "Pelaporan penganiayaan ringan di Jalan Jawa No. 13, Jombang, Jember",
|
||||
"status": "resolved",
|
||||
"category": "Satwa",
|
||||
"type": "Pidana Tertentu",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"category": "Penganiayaan Ringan",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Jawa No. 13, Jombang, Jember",
|
||||
"latitude": -8.227294841217901,
|
||||
"longitude": 113.3512174459733
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -359,36 +359,36 @@
|
|||
"incidents": [
|
||||
{
|
||||
"id": "CI-3509-2256-2024",
|
||||
"timestamp": "2024-08-25T09:25:00.000Z",
|
||||
"description": "Kasus penyelenggaraan pemilu Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"timestamp": "2024-08-08T20:58:00.000Z",
|
||||
"description": "Pelaporan fidusia di Toko Jombang, Jalan Cendrawasih, Jember",
|
||||
"status": "resolved",
|
||||
"category": "Penyelenggaraan Pemilu",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"category": "Fidusia",
|
||||
"type": "Pidana Tertentu",
|
||||
"address": "Toko Jombang, Jalan Cendrawasih, Jember",
|
||||
"latitude": -8.221020024793528,
|
||||
"longitude": 113.3600760414435
|
||||
},
|
||||
{
|
||||
"id": "CI-3509-2257-2024",
|
||||
"timestamp": "2024-08-30T03:36:00.000Z",
|
||||
"description": "Laporan pekerjakan anak terjadi pada Fri Aug 30 2024 10:36:00 GMT+0700 (Western Indonesia Time) di pasar Jombang",
|
||||
"timestamp": "2024-08-01T05:39:00.000Z",
|
||||
"description": "Insiden pengrusakan terjadi di pasar Jombang",
|
||||
"status": "resolved",
|
||||
"category": "Pekerjakan Anak",
|
||||
"category": "Pengrusakan",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"address": "Toko Jombang, Jalan Srikandi, Jember",
|
||||
"latitude": -8.220330780964309,
|
||||
"longitude": 113.3575533505413
|
||||
},
|
||||
{
|
||||
"id": "CI-3509-2258-2024",
|
||||
"timestamp": "2024-08-21T13:52:00.000Z",
|
||||
"description": "Kasus menerima suap Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"timestamp": "2024-08-24T03:35:00.000Z",
|
||||
"description": "Laporan trafficking in person terjadi pada Sat Aug 24 2024 10:35:00 GMT+0700 (Western Indonesia Time) di wilayah Jombang",
|
||||
"status": "resolved",
|
||||
"category": "Menerima Suap",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"category": "Trafficking In Person",
|
||||
"type": "Pidana Tertentu",
|
||||
"address": "Alun-alun Jombang, Jalan Jawa, Jember",
|
||||
"latitude": -8.225862928612479,
|
||||
"longitude": 113.3545810955381
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -419,25 +419,25 @@
|
|||
"incidents": [
|
||||
{
|
||||
"id": "CI-3509-2259-2024",
|
||||
"timestamp": "2024-09-12T22:50:00.000Z",
|
||||
"description": "Kejadian korupsi di pasar Jombang",
|
||||
"timestamp": "2024-09-01T12:23:00.000Z",
|
||||
"description": "Kasus premanisme terjadi di Jalan Cendrawasih",
|
||||
"status": "resolved",
|
||||
"category": "Korupsi",
|
||||
"type": "Korupsi",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"category": "Premanisme",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Cendrawasih No. 57, Jombang, Jember",
|
||||
"latitude": -8.223364642360638,
|
||||
"longitude": 113.3587958397817
|
||||
},
|
||||
{
|
||||
"id": "CI-3509-2260-2024",
|
||||
"timestamp": "2024-09-01T17:56:00.000Z",
|
||||
"description": "Laporan pemalsuan surat terjadi pada Mon Sep 02 2024 00:56:00 GMT+0700 (Western Indonesia Time) di daerah Jombang",
|
||||
"timestamp": "2024-09-05T14:40:00.000Z",
|
||||
"description": "Kejadian sistem peradilan anak di jalan utama Jombang",
|
||||
"status": "resolved",
|
||||
"category": "Pemalsuan Surat",
|
||||
"category": "Sistem Peradilan Anak",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"address": "Jalan Mastrip Blok E-7, Jombang, Jember",
|
||||
"latitude": -8.214910387946766,
|
||||
"longitude": 113.3617351868134
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -468,38 +468,63 @@
|
|||
"incidents": [
|
||||
{
|
||||
"id": "CI-3509-2261-2024",
|
||||
"timestamp": "2024-10-26T02:32:00.000Z",
|
||||
"description": "Laporan menerima suap terjadi pada Sat Oct 26 2024 09:32:00 GMT+0700 (Western Indonesia Time) di pasar Jombang",
|
||||
"timestamp": "2024-10-11T14:44:00.000Z",
|
||||
"description": "Insiden lahgun senpi/handak/sajam dilaporkan warga setempat di jalan utama Jombang",
|
||||
"status": "resolved",
|
||||
"category": "Menerima Suap",
|
||||
"category": "Lahgun Senpi/Handak/Sajam",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"address": "Jalan Pantai No. 92, Jombang, Jember",
|
||||
"latitude": -8.222114170247359,
|
||||
"longitude": 113.3585291112248
|
||||
},
|
||||
{
|
||||
"id": "CI-3509-2262-2024",
|
||||
"timestamp": "2024-10-25T14:48:00.000Z",
|
||||
"description": "Trafficking In Person dilaporkan di wilayah Jombang",
|
||||
"timestamp": "2024-10-11T20:10:00.000Z",
|
||||
"description": "Insiden perlindungan konsumen terjadi di belakang sekolah Jombang",
|
||||
"status": "resolved",
|
||||
"category": "Trafficking In Person",
|
||||
"category": "Perlindungan Konsumen",
|
||||
"type": "Pidana Tertentu",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"address": "Sekolah Jombang, Jalan Jawa, Jember",
|
||||
"latitude": -8.229801790715758,
|
||||
"longitude": 113.3609559436243
|
||||
},
|
||||
{
|
||||
"id": "CI-3509-2263-2024",
|
||||
"timestamp": "2024-10-31T12:47:00.000Z",
|
||||
"description": "Kasus money loudering Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"timestamp": "2024-10-22T21:08:00.000Z",
|
||||
"description": "Perlindungan Anak terdeteksi di belakang perempatan Jombang pada 4:08:00 AM",
|
||||
"status": "resolved",
|
||||
"category": "Money Loudering",
|
||||
"type": "Pidana Tertentu",
|
||||
"address": "Jalan Raya Sumberbaru No. 1, Jombang, Jember",
|
||||
"latitude": -8.207667404400098,
|
||||
"longitude": 113.3497229500003
|
||||
"category": "Perlindungan Anak",
|
||||
"type": "Pidana Umum",
|
||||
"address": "Jalan Srikandi No. 64, Jombang, Jember",
|
||||
"latitude": -8.22081341311974,
|
||||
"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