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:
vergiLgood1 2025-04-28 00:14:16 +07:00
parent 29925dc1b9
commit 6f89892d8c
14 changed files with 1481 additions and 383 deletions

View File

@ -219,7 +219,6 @@ export async function getCrimeByYearAndMonth(
},
},
},
take: 10,
});
return crimes.map((crime) => {

View File

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

View File

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

View File

@ -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">

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
@ -87,22 +89,36 @@ export default function CrimeMap() {
return title
}
// Create map filter controls - now MapView will only render these in fullscreen mode
const mapFilterControls = (
<MapFilterControl
selectedYear={selectedYear}
selectedMonth={selectedMonth}
availableYears={availableYears || []}
yearsLoading={yearsLoading}
onYearChange={setSelectedYear}
onMonthChange={setSelectedMonth}
onApplyFilters={applyFilters}
onResetFilters={resetFilters}
/>
)
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between">
<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) => (
{/* {allIncidents?.map((incident) => (
<CrimeMarker key={incident.id} incident={incident} onClick={handleIncidentClick} />
))}
))} */}
{/* Popup for selected incident */}
{selectedIncident && (

View File

@ -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,39 +39,44 @@ 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
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' }>);
}
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,
@ -80,145 +85,443 @@ export default function DistrictLayer({
longitude: e.lngLat.lng,
latitude: e.lngLat.lat,
...crimeData,
};
}
if (onClick) {
onClick(district);
onClick(district)
} else {
setSelectedDistrict(district);
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] || {}
console.log("Hover district:", districtId, "found data:", crimeData)
// 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;
// Skip if map is not available
if (!map) return
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 vector tile source
map.getMap().addSource('districts', {
type: 'vector',
url: `mapbox://${tilesetId}`
});
map.getMap().addSource("districts", {
type: "vector",
url: `mapbox://${tilesetId}`,
})
// 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
// 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,
],
'fill-opacity': 0.6,
}
});
CRIME_RATE_COLORS.default,
]
// Add the line layer for district borders
map.getMap().addLayer({
id: 'district-line',
type: 'line',
source: 'districts',
'source-layer': 'Districts',
// 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: {
'line-color': '#ffffff',
'line-width': 1,
'line-opacity': 0.5,
"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,
},
},
firstSymbolId,
)
}
if (!map.getMap().getLayer("district-labels")) {
// Add district labels with improved visibility and responsive sizing
map.getMap().addLayer(
{
id: "district-labels",
type: "symbol",
source: "districts",
"source-layer": "Districts",
layout: {
"text-field": ["get", "nama"],
"text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
// Make text size responsive to zoom level
"text-size": [
"interpolate",
["linear"],
["zoom"],
9,
8, // At zoom level 9, size 8px
12,
12, // At zoom level 12, size 12px
15,
14, // At zoom level 15, size 14px
],
"text-allow-overlap": false,
"text-ignore-placement": false,
// Adjust text anchor based on zoom level
"text-anchor": "center",
"text-justify": "center",
"text-max-width": 8,
// Show labels only at certain zoom levels
"text-optional": true,
"symbol-sort-key": ["get", "kode_kec"], // Sort labels by district code
"symbol-z-order": "source",
},
paint: {
"text-color": "#000000",
"text-halo-color": "#ffffff",
"text-halo-width": 2,
"text-halo-blur": 1,
// Fade in text opacity based on zoom level
"text-opacity": [
"interpolate",
["linear"],
["zoom"],
8,
0, // Fully transparent at zoom level 8
9,
0.6, // 60% opacity at zoom level 9
10,
1.0, // Fully opaque at zoom level 10
],
},
},
firstSymbolId,
)
}
// Create a source for clustered incident markers
if (crimes.length > 0 && !map.getMap().getSource("crime-incidents")) {
// 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));
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);
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();
onStyleLoad()
} else {
// Otherwise, wait for the style.load event
map.once('style.load', onStyleLoad);
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 (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))
// 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;
// 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]);
}
}, [map, visible, tilesetId, crimes])
// Update the crime data when it changes
useEffect(() => {
if (!map || !layersAdded.current) return;
if (!map || !layersAdded.current) return
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', [
'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);
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,9 +541,7 @@ 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>
<span className="ml-2 text-xs font-semibold text-gray-500">({hoverInfo.feature.properties.level})</span>
)}
</p>
)}
@ -259,5 +560,5 @@ export default function DistrictLayer({
/>
)}
</>
);
)
}

View File

@ -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],
)
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)
}
}, [onMoveEnd]);
return (
<div className={`relative ${className}`}>
<div className="absolute inset-0 z-10 pointer-events-none" />
<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)}
ref={(ref) => setMapRef(ref)}
mapStyle={mapStyle}
mapboxAccessToken={mapboxApiAccessToken}
initialViewState={defaultViewState}
onLoad={handleMapLoad}
onMoveEnd={handleMoveEnd}
interactiveLayerIds={['district-fill']}
interactiveLayerIds={["district-fill", "clusters", "unclustered-point"]}
attributionControl={false}
style={{ width, height }}
>
{children}
<NavigationControl position="top-right" />
<FullscreenControl position="top-right" />
<ScaleControl position="bottom-right" />
<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>
)
}

View File

@ -22,7 +22,6 @@ type CrimeMarkerProps = {
export default function CrimeMarker({ incident, onClick }: CrimeMarkerProps) {
console.log("CrimeMarker", incident)
return (
<Marker

View File

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

View File

@ -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"]
}
}
}