feat: update incident logs structure and add popup component

- Modified IIncidentLogs interface to include user details and additional fields.
- Implemented IncidentLogsPopup component for displaying incident details on the map.
- Added functionality to format timestamps, display severity badges, and show reporter information.
- Created SQL functions for retrieving nearby units and updating location distances in the database.
- Added spatial indexes to optimize queries related to units and locations.
This commit is contained in:
vergiLgood1 2025-05-14 19:21:36 +07:00
parent 849b3c1ae3
commit b9f69ade3b
9 changed files with 1780 additions and 978 deletions

View File

@ -192,6 +192,27 @@ export async function getRecentIncidents(): Promise<IIncidentLogs[]> {
time: "desc",
},
include: {
user: {
select: {
id: true,
email: true,
phone: true,
role: {
select: {
id: true,
name: true,
}
},
profile: {
select: {
username: true,
first_name: true,
last_name: true,
avatar: true,
}
}
},
},
crime_categories: {
select: {
name: true,
@ -213,10 +234,22 @@ export async function getRecentIncidents(): Promise<IIncidentLogs[]> {
},
});
if (!incidents) {
console.error("No incidents found");
return [];
}
// Map DB result to IIncidentLogs interface
return incidents.map((incident) => ({
id: incident.id,
user_id: incident.user_id,
role_id: incident.user?.role?.id,
name: `${incident.user?.profile?.first_name} ${incident.user?.profile?.last_name}`,
email: incident.user?.email,
phone: incident.user?.phone ?? "",
role: incident.user?.role?.name,
avatar: incident.user?.profile?.avatar ?? "",
latitude: incident.locations?.latitude ?? null,
longitude: incident.locations?.longitude ?? null,
district: incident.locations.districts.name ?? "",

View File

@ -1,7 +1,9 @@
"use client"
import { useEffect, useCallback, useRef } from "react"
import { useEffect, useCallback, useRef, useState } from "react"
import type { IIncidentLogs } from "@/app/_utils/types/crimes"
import { BASE_BEARING, BASE_DURATION, BASE_PITCH, ZOOM_3D } from "@/app/_utils/const/map"
import IncidentLogsPopup from "../pop-up/incident-logs-popup"
interface RecentIncidentsLayerProps {
visible?: boolean
@ -11,6 +13,8 @@ interface RecentIncidentsLayerProps {
export default function RecentIncidentsLayer({ visible = false, map, incidents = [] }: RecentIncidentsLayerProps) {
const isInteractingWithMarker = useRef(false)
const animationFrameRef = useRef<number | null>(null)
const [selectedIncident, setSelectedIncident] = useState<IIncidentLogs | null>(null)
// Filter incidents from the last 24 hours
const recentIncidents = incidents.filter((incident) => {
@ -22,6 +26,9 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
return timeDiff <= 86400000
})
// Split incidents into very recent (2 hours) and regular recent
const twoHoursInMs = 2 * 60 * 60 * 1000 // 2 hours in milliseconds
const handleIncidentClick = useCallback(
(e: any) => {
if (!map) return
@ -45,36 +52,36 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
id: incident.properties.id,
description: incident.properties.description,
status: incident.properties?.status || "Active",
verified: incident.properties?.status,
longitude: (incident.geometry as any).coordinates[0],
latitude: (incident.geometry as any).coordinates[1],
timestamp: new Date(incident.properties.timestamp || Date.now()),
category: incident.properties.category,
address: incident.properties.address,
district: incident.properties.district,
severity: incident.properties.severity,
source: incident.properties.source,
user_id: incident.properties.user_id,
name: incident.properties.name,
email: incident.properties.email,
phone: incident.properties.telephone,
avatar: incident.properties.avatar,
role_id: incident.properties.role_id,
role: incident.properties.role,
isVeryRecent: incident.properties.isVeryRecent,
}
// console.log("Recent incident clicked:", incidentDetails)
// Ensure markers stay visible
if (map.getLayer("recent-incidents")) {
map.setLayoutProperty("recent-incidents", "visibility", "visible")
}
// First fly to the incident location
// Fly to the incident location
map.flyTo({
center: [incidentDetails.longitude, incidentDetails.latitude],
zoom: 15,
bearing: 0,
pitch: 45,
duration: 2000,
zoom: ZOOM_3D,
bearing: BASE_BEARING,
pitch: BASE_PITCH,
duration: BASE_DURATION,
})
// Dispatch the incident_click event to show the popup
const customEvent = new CustomEvent("incident_click", {
detail: incidentDetails,
bubbles: true,
})
map.getCanvas().dispatchEvent(customEvent)
document.dispatchEvent(customEvent)
// Set selected incident for the popup
setSelectedIncident(incidentDetails)
// Reset the flag after a delay
setTimeout(() => {
@ -84,15 +91,25 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
[map],
)
// Handle popup close
const handleClosePopup = useCallback(() => {
setSelectedIncident(null)
}, [])
useEffect(() => {
if (!map || !visible) return
// console.log(`Setting up recent incidents layer with ${recentIncidents.length} incidents from the last 24 hours`)
// Convert incidents to GeoJSON with an additional property for recency
const now = new Date().getTime()
// Convert incidents to GeoJSON
const recentData = {
type: "FeatureCollection" as const,
features: recentIncidents.map((incident) => ({
features: recentIncidents.map((incident) => {
const timestamp = incident.timestamp ? new Date(incident.timestamp).getTime() : now
const timeDiff = now - timestamp
const isVeryRecent = timeDiff <= twoHoursInMs
return {
type: "Feature" as const,
geometry: {
type: "Point" as const,
@ -100,7 +117,13 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
},
properties: {
id: incident.id,
role_id: incident.role_id,
user_id: incident.user_id,
name: incident.name,
email: incident.email,
telephone: incident.phone,
avatar: incident.avatar,
role: incident.role,
address: incident.address,
description: incident.description,
timestamp: incident.timestamp ? incident.timestamp.toString() : new Date().toString(),
@ -109,8 +132,11 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
severity: incident.severity,
status: incident.verified,
source: incident.source,
isVeryRecent: isVeryRecent, // Add this property to identify very recent incidents
timeDiff: timeDiff, // Time difference in milliseconds
},
})),
}
}),
}
const setupLayerAndSource = () => {
@ -136,31 +162,31 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
}
}
// Check if layer exists already
if (!map.getLayer("recent-incidents")) {
// Add the pulsing glow layer for very recent incidents (2 hours or less)
if (!map.getLayer("very-recent-incidents-pulse")) {
map.addLayer(
{
id: "recent-incidents",
id: "very-recent-incidents-pulse",
type: "circle",
source: "recent-incidents-source",
filter: ["==", ["get", "isVeryRecent"], true],
paint: {
"circle-color": "#FF5252", // Red color for recent incidents
"circle-color": "#FF0000",
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
7,
4, // Slightly larger at lower zooms for visibility
12,
8,
12,
16,
15,
12, // Larger maximum size
24,
],
"circle-opacity": 0.3,
"circle-stroke-width": 2,
"circle-stroke-color": "#FFFFFF",
"circle-opacity": 0.8,
// Add a pulsing effect
"circle-stroke-opacity": ["interpolate", ["linear"], ["zoom"], 7, 0.5, 15, 0.8],
"circle-stroke-color": "#FF0000",
"circle-stroke-opacity": 0.5,
},
layout: {
visibility: visible ? "visible" : "none",
@ -168,8 +194,12 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
},
firstSymbolId,
)
} else {
map.setLayoutProperty("very-recent-incidents-pulse", "visibility", visible ? "visible" : "none")
}
// Add a glow effect with a larger circle behind
// Add regular recent incidents glow
if (!map.getLayer("recent-incidents-glow")) {
map.addLayer(
{
id: "recent-incidents-glow",
@ -185,7 +215,46 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
visibility: visible ? "visible" : "none",
},
},
"recent-incidents",
"very-recent-incidents-pulse",
)
} else {
map.setLayoutProperty("recent-incidents-glow", "visibility", visible ? "visible" : "none")
}
// Check if layer exists already for the main marker dots
if (!map.getLayer("recent-incidents")) {
map.addLayer(
{
id: "recent-incidents",
type: "circle",
source: "recent-incidents-source",
paint: {
"circle-color": [
"case",
["==", ["get", "isVeryRecent"], true],
"#FF0000", // Bright red for very recent
"#FF5252", // Standard red for older incidents
],
"circle-radius": [
"interpolate",
["linear"],
["zoom"],
7,
4,
12,
8,
15,
12,
],
"circle-stroke-width": 2,
"circle-stroke-color": "#FFFFFF",
"circle-opacity": 0.8,
},
layout: {
visibility: visible ? "visible" : "none",
},
},
"recent-incidents-glow",
)
// Add mouse events
@ -199,7 +268,42 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
} else {
// Update existing layer visibility
map.setLayoutProperty("recent-incidents", "visibility", visible ? "visible" : "none")
map.setLayoutProperty("recent-incidents-glow", "visibility", visible ? "visible" : "none")
}
// Create animation for very recent incidents
const animatePulse = () => {
if (!map || !map.getLayer("very-recent-incidents-pulse")) {
return
}
// Create a pulsing effect by changing the size and opacity
const pulseSize = (Date.now() % 2000) / 2000 // Values from 0 to 1 every 2 seconds
const pulseOpacity = 0.7 - pulseSize * 0.5 // Opacity oscillates between 0.2 and 0.7
const scaleFactor = 1 + pulseSize * 0.5 // Size oscillates between 1x and 1.5x
map.setPaintProperty("very-recent-incidents-pulse", "circle-opacity", pulseOpacity)
map.setPaintProperty("very-recent-incidents-pulse", "circle-radius", [
"interpolate",
["linear"],
["zoom"],
7,
8 * scaleFactor,
12,
16 * scaleFactor,
15,
24 * scaleFactor,
])
// Continue animation
animationFrameRef.current = requestAnimationFrame(animatePulse)
}
// Start animation if visible
if (visible) {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
animationFrameRef.current = requestAnimationFrame(animatePulse)
}
// Ensure click handler is properly registered
@ -230,8 +334,30 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
if (map) {
map.off("click", "recent-incidents", handleIncidentClick)
}
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
}
}, [map, visible, recentIncidents, handleIncidentClick])
return null
// Close popup when layer becomes invisible
useEffect(() => {
if (!visible) {
setSelectedIncident(null)
}
}, [visible])
return (
<>
{/* Popup component */}
{selectedIncident && (
<IncidentLogsPopup
longitude={selectedIncident.longitude}
latitude={selectedIncident.latitude}
onClose={handleClosePopup}
incident={selectedIncident}
/>
)}
</>
)
}

View File

@ -4,9 +4,10 @@ import { useEffect, useMemo, useState, useCallback } from "react"
import { Layer, Source } from "react-map-gl/mapbox"
import type { ICrimes } from "@/app/_utils/types/crimes"
import type mapboxgl from "mapbox-gl"
import { format } from "date-fns"
import { format, getMonth, getYear, parseISO } from "date-fns"
import { calculateAverageTimeOfDay } from "@/app/_utils/time"
import TimelinePopup from "../pop-up/timeline-popup"
import { BASE_BEARING, BASE_DURATION, BASE_LATITUDE, BASE_LONGITUDE, BASE_PITCH, BASE_ZOOM, ZOOM_3D } from "@/app/_utils/const/map"
interface TimelineLayerProps {
crimes: ICrimes[]
@ -33,14 +34,31 @@ export default function TimelineLayer({
// Process district data to extract average incident times
const districtTimeData = useMemo(() => {
// Convert year and month to numbers for comparison
const selectedYear = parseInt(year);
const selectedMonth = parseInt(month) - 1; // JS months are 0-indexed
const isMonthFiltered = month !== "all" && !isNaN(selectedMonth);
const isYearFiltered = !isNaN(selectedYear);
// Group incidents by district
const districtGroups = new Map<
string,
{
districtId: string
districtName: string
incidents: Array<{ timestamp: Date; category: string }>
incidents: Array<{
timestamp: Date;
category: string;
id?: string;
title?: string;
}>
center: [number, number]
filteredIncidents: Array<{
timestamp: Date;
category: string;
id?: string;
title?: string;
}>
}
>()
@ -49,7 +67,7 @@ export default function TimelineLayer({
// Initialize district group if not exists
if (!districtGroups.has(crime.district_id)) {
// Find a central location for the district from any incident
// Find a central location for the district
const centerIncident = crime.crime_incidents.find((inc) => inc.locations?.latitude && inc.locations?.longitude)
const center: [number, number] = centerIncident
@ -60,22 +78,47 @@ export default function TimelineLayer({
districtId: crime.district_id,
districtName: crime.districts.name,
incidents: [],
filteredIncidents: [],
center,
})
}
// Filter incidents appropriately before adding
// Add all incidents first (for all-time stats)
crime.crime_incidents.forEach((incident) => {
// Skip invalid incidents
if (!incident.timestamp) return
if (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) return
// Add to appropriate district group
const incidentDate = new Date(incident.timestamp);
const group = districtGroups.get(crime.district_id)
if (group) {
// Add to all incidents regardless of filters
group.incidents.push({
timestamp: new Date(incident.timestamp),
timestamp: incidentDate,
category: incident.crime_categories.name,
id: incident.id,
title: incident.description || incident.crime_categories.name
})
// Apply filters for filtered incidents
const incidentYear = getYear(incidentDate);
const incidentMonth = getMonth(incidentDate);
// Apply category filter
if (filterCategory !== "all" && incident.crime_categories.name !== filterCategory) return;
// Apply year filter
if (isYearFiltered && incidentYear !== selectedYear) return;
// Apply month filter
if (isMonthFiltered && incidentMonth !== selectedMonth) return;
// Add to filtered incidents
group.filteredIncidents.push({
timestamp: incidentDate,
category: incident.crime_categories.name,
id: incident.id,
title: incident.description || incident.crime_categories.name
})
}
})
@ -83,9 +126,27 @@ export default function TimelineLayer({
// Calculate average time for each district
const result = Array.from(districtGroups.values())
.filter((group) => group.incidents.length > 0 && group.center[0] !== 0)
.filter((group) => {
// Only include districts that have incidents after filtering
const incidentsToUse = useAllData ? group.incidents : group.filteredIncidents;
return incidentsToUse.length > 0 && group.center[0] !== 0;
})
.map((group) => {
const avgTimeInfo = calculateAverageTimeOfDay(group.incidents.map((inc) => inc.timestamp))
// Choose which set of incidents to use
const incidentsToUse = useAllData ? group.incidents : group.filteredIncidents;
// Calculate average times based on filtered or all incidents
const avgTimeInfo = calculateAverageTimeOfDay(incidentsToUse.map((inc) => inc.timestamp))
// Format incident data for display in timeline
const formattedIncidents = incidentsToUse
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) // Sort by most recent first
.map(incident => ({
id: incident.id || Math.random().toString(36).substring(2),
title: incident.title || 'Incident',
time: format(incident.timestamp, 'MMM d, yyyy HH:mm'),
category: incident.category
}))
return {
id: group.districtId,
@ -95,23 +156,32 @@ export default function TimelineLayer({
avgMinute: avgTimeInfo.minute,
formattedTime: avgTimeInfo.formattedTime,
timeDescription: avgTimeInfo.description,
totalIncidents: group.incidents.length,
totalIncidents: incidentsToUse.length,
timeOfDay: avgTimeInfo.timeOfDay,
earliestTime: format(avgTimeInfo.earliest, "p"),
latestTime: format(avgTimeInfo.latest, "p"),
mostFrequentHour: avgTimeInfo.mostFrequentHour,
categoryCounts: group.incidents.reduce(
categoryCounts: incidentsToUse.reduce(
(acc, inc) => {
acc[inc.category] = (acc[inc.category] || 0) + 1
return acc
},
{} as Record<string, number>,
),
incidents: formattedIncidents,
selectedFilters: {
year: isYearFiltered ? selectedYear.toString() : "all",
month: isMonthFiltered ? (selectedMonth + 1).toString().padStart(2, '0') : "all",
category: filterCategory,
label: `${isYearFiltered ? selectedYear : "All years"}${isMonthFiltered ? ', ' + format(new Date(0, selectedMonth), 'MMMM') : ''}`
},
allTimeCount: group.incidents.length,
useAllData: useAllData
}
})
return result
}, [crimes, filterCategory, year, month])
}, [crimes, filterCategory, year, month, useAllData])
// Convert processed data to GeoJSON for display
const timelineGeoJSON = useMemo(() => {
@ -158,10 +228,10 @@ export default function TimelineLayer({
if (map) {
map.flyTo({
center: districtData.center,
zoom: 12,
duration: 1000,
pitch: 45,
bearing: 0,
zoom: ZOOM_3D,
duration: BASE_DURATION,
pitch: BASE_PITCH,
bearing: BASE_BEARING,
})
}
@ -173,6 +243,14 @@ export default function TimelineLayer({
// Handle popup close
const handleClosePopup = useCallback(() => {
if (map) {
map.easeTo({
zoom: BASE_ZOOM,
duration: BASE_DURATION,
pitch: BASE_PITCH,
bearing: BASE_BEARING,
})
}
setSelectedDistrict(null)
}, [])
@ -250,7 +328,13 @@ export default function TimelineLayer({
"#263238",
"#4CAF50", // Default color
],
"circle-radius": 18,
"circle-radius": [
"interpolate",
["linear"],
["get", "totalIncidents"],
1, 15, // Min size
100, 25 // Max size
],
"circle-stroke-width": 2,
"circle-stroke-color": "#000000",
"circle-opacity": 0.9,

View File

@ -0,0 +1,255 @@
"use client"
import { Popup } from "react-map-gl/mapbox"
import { Badge } from "@/app/_components/ui/badge"
import { Card } from "@/app/_components/ui/card"
import { Separator } from "@/app/_components/ui/separator"
import { Button } from "@/app/_components/ui/button"
import {
Clock,
MapPin,
Navigation,
X,
AlertCircle,
Calendar,
User,
Tag,
Building2
} from "lucide-react"
import { formatDistanceToNow } from "date-fns"
import { IconBrandGmail, IconPhone } from "@tabler/icons-react"
interface IncidentLogsPopupProps {
longitude: number
latitude: number
onClose: () => void
incident: {
id: string
description?: string
category?: string
address?: string
timestamp: Date
district?: string
severity?: string
source?: string
status?: string
verified?: boolean | string
user_id?: string
name?: string
email?: string
phone?: string
avatar?: string
role_id?: string
role?: string
isVeryRecent?: boolean
}
}
export default function IncidentLogsPopup({
longitude,
latitude,
onClose,
incident,
}: IncidentLogsPopupProps) {
// Format timestamp in a human-readable way
const timeAgo = formatDistanceToNow(new Date(incident.timestamp), { addSuffix: true })
// Get severity badge color
const getSeverityColor = (severity?: string) => {
switch (severity?.toLowerCase()) {
case 'high':
return 'bg-red-500 text-white'
case 'medium':
return 'bg-orange-500 text-white'
case 'low':
return 'bg-yellow-500 text-black'
default:
return 'bg-gray-500 text-white'
}
}
// Format verification status
const verificationStatus = typeof incident.verified === 'boolean'
? incident.verified
: incident.verified === 'true' || incident.verified === '1'
return (
<Popup
longitude={longitude}
latitude={latitude}
closeButton={false}
closeOnClick={false}
onClose={onClose}
anchor="bottom"
maxWidth="320px"
className="incident-logs-popup z-50"
>
<div className="relative">
<Card
className="bg-background p-0 w-full max-w-[320px] shadow-xl border-0 overflow-hidden border-l-4 border-l-red-600"
>
<div className="p-4 relative">
{/* Custom close button */}
<Button
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-6 w-6 rounded-full bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700"
onClick={onClose}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
<div className="flex items-center justify-between mb-3">
<h3 className="font-bold text-base flex items-center gap-1.5">
<AlertCircle className="h-4 w-4 text-red-600" />
{incident.category || "Incident Report"}
</h3>
<Badge variant="outline" className={`${getSeverityColor(incident.severity)}`}>
{incident.severity || "Unknown"} Priority
</Badge>
</div>
<div className="grid grid-cols-1 gap-2 text-sm">
{incident.description && (
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Description</p>
<p className="font-medium">{incident.description}</p>
</div>
)}
<div className="grid grid-cols-2 gap-2 mt-1">
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Time</p>
<p className="flex items-center">
<Clock className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-blue-500" />
<span className="font-medium">{timeAgo}</span>
</p>
</div>
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Status</p>
<p className="flex items-center">
<Badge variant={verificationStatus ? "default" : "secondary"} className="h-5">
{verificationStatus ? "Verified" : "Unverified"}
</Badge>
</p>
</div>
</div>
{incident.address && (
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Location</p>
<p className="flex items-center">
<MapPin className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-red-500" />
<span className="font-medium">{incident.address}</span>
</p>
</div>
)}
{incident.district && (
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">District</p>
<p className="flex items-center">
<Building2 className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-purple-500" />
<span className="font-medium">{incident.district}</span>
</p>
</div>
)}
{incident.source && (
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Source</p>
<p className="flex items-center">
<Tag className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-green-500" />
<span className="font-medium">{incident.source}</span>
</p>
</div>
)}
{/* Reporter information section */}
{(incident.name || incident.user_id || incident.email || incident.phone) && (
<div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-1">Reporter Details</p>
<div className="rounded-md border border-border p-2 space-y-1">
{incident.name && (
<p className="flex items-center text-xs">
<User className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-amber-500" />
<span className="font-medium">{incident.name}</span>
{incident.role && (
<Badge variant="outline" className="ml-1.5 text-[10px] h-4 px-1">
{incident.role}
</Badge>
)}
</p>
)}
{incident.email && (
<p className="text-xs text-muted-foreground truncate">
<IconBrandGmail className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-blue-500" />
{incident.email}
</p>
)}
{incident.phone && (
<p className="text-xs text-muted-foreground">
<IconPhone className="inline-block h-3.5 w-3.5 mr-1.5 shrink-0 text-green-500" />
{incident.phone}
</p>
)}
</div>
</div>
)}
</div>
<Separator className="my-3" />
<div className="mt-3 pt-0">
<p className="text-xs text-muted-foreground flex items-center">
<Navigation className="inline-block h-3 w-3 mr-1 shrink-0" />
Coordinates: {latitude.toFixed(6)}, {longitude.toFixed(6)}
</p>
<p className="text-xs text-muted-foreground mt-1">Incident ID: {incident.id}</p>
</div>
</div>
</Card>
{/* Connection line */}
<div
className="absolute bottom-0 left-1/2 transform -translate-x-1/2 translate-y-full"
style={{
width: '2px',
height: '20px',
backgroundColor: 'red',
boxShadow: '0 0 4px rgba(0, 0, 0, 0.3)'
}}
/>
{/* Connection dot */}
<div
className="absolute bottom-0 left-1/2 transform -translate-x-1/2 translate-y-full"
style={{
width: '6px',
height: '6px',
backgroundColor: 'red',
borderRadius: '50%',
marginTop: '20px',
boxShadow: '0 0 4px rgba(0, 0, 0, 0.3)'
}}
/>
{/* Pulsing effect for very recent incidents */}
{incident.isVeryRecent && (
<div
className="absolute bottom-0 left-1/2 transform -translate-x-1/2 translate-y-full animate-ping"
style={{
width: '12px',
height: '12px',
backgroundColor: 'rgba(255, 0, 0, 0.3)',
borderRadius: '50%',
marginTop: '20px',
}}
/>
)}
</div>
</Popup>
)
}

View File

@ -1,7 +1,8 @@
"use client"
import { useState } from "react"
import { Popup } from "react-map-gl/mapbox"
import { X } from 'lucide-react'
import { X, ChevronLeft, ChevronRight, Filter } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../ui/card"
import { Button } from "../../ui/button"
@ -22,6 +23,20 @@ interface TimelinePopupProps {
mostFrequentHour: number
categoryCounts: Record<string, number>
timeOfDay: string
incidents?: Array<{
id: string
title: string
time: string
category: string
}>
selectedFilters?: {
year: string
month: string
category: string
label: string
}
allTimeCount?: number
useAllData?: boolean
}
}
@ -31,6 +46,10 @@ export default function TimelinePopup({
onClose,
district,
}: TimelinePopupProps) {
// Pagination state
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 3
// Get top 5 categories
const topCategories = Object.entries(district.categoryCounts)
.sort(([, countA], [, countB]) => countB - countA)
@ -52,6 +71,61 @@ export default function TimelinePopup({
}
}
// Get text color for time of day
const getTextColorForTimeOfDay = (timeOfDay: string) => {
switch (timeOfDay) {
case "morning":
return "text-yellow-400"
case "afternoon":
return "text-orange-500"
case "evening":
return "text-indigo-600"
case "night":
return "text-slate-800"
default:
return "text-green-500"
}
}
// Get paginated incidents
const getPaginatedIncidents = () => {
if (!district.incidents) return []
const startIndex = (currentPage - 1) * itemsPerPage
return district.incidents.slice(startIndex, startIndex + itemsPerPage)
}
// Calculate total pages
const totalPages = district.incidents ? Math.ceil(district.incidents.length / itemsPerPage) : 0
// Handle page navigation
const goToNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(prev => prev + 1)
}
}
const goToPrevPage = () => {
if (currentPage > 1) {
setCurrentPage(prev => prev - 1)
}
}
// Get current incidents for display
const currentIncidents = getPaginatedIncidents()
// Extract filter info
const filterLabel = district.selectedFilters?.label || "All data";
const isFiltered = district.selectedFilters?.year !== "all" || district.selectedFilters?.month !== "all";
const categoryFilter = district.selectedFilters?.category !== "all"
? district.selectedFilters?.category
: null;
// Get percentage of incidents in the time window compared to all time
const percentageOfAll = district.allTimeCount && district.allTimeCount > 0 && district.totalIncidents !== district.allTimeCount
? Math.round((district.totalIncidents / district.allTimeCount) * 100)
: null;
return (
<Popup
longitude={longitude}
@ -76,8 +150,14 @@ export default function TimelinePopup({
<X className="h-4 w-4" />
</Button>
</div>
<CardDescription className="text-xs">
Average incident time analysis
<CardDescription className="text-xs flex items-center gap-1">
<span>Average incident time analysis</span>
{isFiltered && (
<Badge variant="outline" className="h-4 text-[10px] gap-0.5 px-1 py-0 flex items-center">
<Filter className="h-2.5 w-2.5" />
{filterLabel}
</Badge>
)}
</CardDescription>
</CardHeader>
<CardContent className="p-3 pt-0">
@ -88,8 +168,16 @@ export default function TimelinePopup({
{district.timeDescription}
</Badge>
</div>
<div className="text-xs text-muted-foreground">
Based on {district.totalIncidents} incidents
<div className="text-xs text-muted-foreground flex items-center gap-1">
<span>Based on {district.totalIncidents} incidents</span>
{percentageOfAll && (
<span className="text-[10px]">({percentageOfAll}% of all time)</span>
)}
{categoryFilter && (
<Badge variant="secondary" className="h-4 text-[10px] px-1 py-0">
{categoryFilter}
</Badge>
)}
</div>
</div>
@ -104,8 +192,8 @@ export default function TimelinePopup({
</div>
</div>
<div className="border-t border-border pt-2">
<div className="text-xs font-medium mb-1">Top incident types:</div>
<div className="border-t border-border pt-2 mb-3">
<div className={`${getTextColorForTimeOfDay(district.timeOfDay)} text-sm font-medium mb-1`}>Top incident types:</div>
<div className="space-y-1">
{topCategories.map(([category, count]) => (
<div key={category} className="flex justify-between">
@ -115,6 +203,56 @@ export default function TimelinePopup({
))}
</div>
</div>
{district.incidents && district.incidents.length > 0 && (
<div className="border-t border-border pt-2">
<div className="flex justify-between items-center mb-2">
<div className={`${getTextColorForTimeOfDay(district.timeOfDay)} text-sm font-medium`}>Incidents Timeline:</div>
<div className="text-xs text-muted-foreground">
{currentPage} of {totalPages}
</div>
</div>
<div className="space-y-3 min-h-[120px]">
{currentIncidents.map((incident) => (
<div key={incident.id} className="last:mb-0">
<div className="text-xs font-semibold mb-0.5">
{incident.category}
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-muted-foreground line-clamp-1">{incident.title}</span>
<Badge variant="outline" className={`${getTextColorForTimeOfDay(district.timeOfDay)} text-[10px] h-5 ml-1 shrink-0`} >
{incident.time}
</Badge>
</div>
</div>
))}
</div>
{/* Pagination Controls */}
<div className="flex justify-between items-center mt-4">
<Button
variant="outline"
size="sm"
className={`${getTimeOfDayColor(district.timeOfDay)} h-7 px-2`}
onClick={goToPrevPage}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className={`${getTimeOfDayColor(district.timeOfDay)} h-7 px-2`}
onClick={goToNextPage}
disabled={currentPage === totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>

View File

@ -61,7 +61,7 @@ export default function UnitPopup({
closeButton={false}
closeOnClick={false}
onClose={onClose}
anchor="top"
anchor="bottom"
maxWidth="320px"
className="unit-popup z-50"
>

View File

@ -25,37 +25,45 @@
/* =========================
2. COLOR UTILITY CLASSES
========================= */
.red-color { color: var(--red); }
.red-bg { background-color: var(--red); }
.red-border { border: 1px solid var(--red); }
.red-color {
color: var(--red);
}
.red-bg {
background-color: var(--red);
}
.red-border {
border: 1px solid var(--red);
}
/* =========================
3. STRIP BAR & ANIMATIONS
========================= */
/* --- Orange Stripe --- */
.strip-bar {
width: max(200vw,2000px);
width: max(200vw, 2000px);
height: 30px;
display: inline-block;
margin-bottom: -5px;
--stripe-color: var(--orange);
--stripe-size: 15px;
--glow-color: rgba(255, 94, 0, .8);
--glow-color: rgba(255, 94, 0, 0.8);
--glow-size: 3px;
background-image: repeating-linear-gradient(-45deg,
background-image: repeating-linear-gradient(
-45deg,
var(--glow-color) calc(-1 * var(--glow-size)),
var(--stripe-color) 0,
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripe-size)),
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size)));
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size))
);
}
/* --- Red Stripe --- */
.strip-bar-red {
width: max(200vw,2000px);
width: max(200vw, 2000px);
height: 30px;
display: inline-block;
margin-bottom: -5px;
@ -64,14 +72,16 @@
--stripe-size: 15px;
--glow-color: rgba(255, 17, 0, 0.8);
--glow-size: 3px;
background-image: repeating-linear-gradient(-45deg,
background-image: repeating-linear-gradient(
-45deg,
var(--glow-color) calc(-1 * var(--glow-size)),
var(--stripe-color) 0,
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripe-size)),
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size)));
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size))
);
}
/* --- Vertical Orange Stripe --- */
@ -80,16 +90,18 @@
transform: translate3d(0, 0, 0);
--stripe-color: var(--orange);
--stripe-size: 15px;
--glow-color: rgba(255, 94, 0, .8);
--glow-color: rgba(255, 94, 0, 0.8);
--glow-size: 3px;
background-image: repeating-linear-gradient(45deg,
background-image: repeating-linear-gradient(
45deg,
var(--glow-color) calc(-1 * var(--glow-size)),
var(--stripe-color) 0,
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripe-size)),
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size)));
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size))
);
}
/* --- Vertical Red Stripe --- */
@ -100,20 +112,26 @@
--stripe-size: 15px;
--glow-color: rgba(255, 17, 0, 0.8);
--glow-size: 3px;
background-image: repeating-linear-gradient(45deg,
background-image: repeating-linear-gradient(
45deg,
var(--glow-color) calc(-1 * var(--glow-size)),
var(--stripe-color) 0,
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripe-size)),
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size)));
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size))
);
}
/* --- Animations for Stripe --- */
@keyframes slideinBg {
from {background-position: top; }
to {background-position: -100px 0px;}
from {
background-position: top;
}
to {
background-position: -100px 0px;
}
}
.strip-animation-vertical {
@ -156,7 +174,7 @@
}
.strip-wrapper {
width: max(200vw,2000px);
width: max(200vw, 2000px);
overflow: hidden;
white-space: nowrap;
}
@ -352,7 +370,6 @@
.mapboxgl-popup {
width: auto;
}
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
@ -593,7 +610,7 @@
text-transform: uppercase;
user-select: none;
white-space: nowrap;
--text-glow-color: rgba(var(--glow-rgb), .5);
--text-glow-color: rgba(var(--glow-rgb), 0.5);
color: var(--text-color);
}
@ -614,7 +631,9 @@
border-style: solid;
border-width: 1px;
border-color: unset;
box-shadow: inset 0 0 0 1px var(--border-glow-color), 0 0 0 1px var(--border-glow-color);
box-shadow:
inset 0 0 0 1px var(--border-glow-color),
0 0 0 1px var(--border-glow-color);
}
.red-bordered {
@ -624,7 +643,9 @@
border-style: solid;
border-width: 1px;
border-color: unset;
box-shadow: inset 0 0 0 1px var(--border-glow-color), 0 0 0 1px var(--border-glow-color);
box-shadow:
inset 0 0 0 1px var(--border-glow-color),
0 0 0 1px var(--border-glow-color);
}
.red-bordered-bottom {
@ -632,7 +653,9 @@
--border-glow-color: rgba(var(--danger-glow-rgb), 0.7);
border-color: unset;
border-bottom: 1px solid red;
box-shadow: inset 0 0 0 1px var(--border-glow-color), 0 0 0 1px var(--border-glow-color);
box-shadow:
inset 0 0 0 1px var(--border-glow-color),
0 0 0 1px var(--border-glow-color);
}
.red-bordered-top {
@ -640,7 +663,9 @@
--border-glow-color: rgba(var(--danger-glow-rgb), 0.7);
border-color: unset;
border-top: 1px solid var(--danger-glow-rgb);
box-shadow: inset 0 0 0 1px var(--border-glow-color), 0 0 0 1px var(--border-glow-color);
box-shadow:
inset 0 0 0 1px var(--border-glow-color),
0 0 0 1px var(--border-glow-color);
}
/* =========================
@ -701,7 +726,8 @@
overflow: hidden;
}
.jajar-genjang .time-countdown {}
.jajar-genjang .time-countdown {
}
.jajar-genjang.danger {
background-color: var(--red);
@ -739,7 +765,8 @@
overflow-y: auto;
}
.item-daerah.danger {}
.item-daerah.danger {
}
.item-daerah .content {
position: absolute;
@ -819,9 +846,13 @@ label#internal {
text-transform: uppercase;
user-select: none;
white-space: nowrap;
--text-glow-color: rgba(var(--glow-rgb), .5);
--text-glow-color: rgba(var(--glow-rgb), 0.5);
color: var(--text-color);
text-shadow: -1px 1px 0 var(--text-glow-color), 1px -1px 0 var(--text-glow-color), -1px -1px 0 var(--text-glow-color), 1px 1px 0 var(--text-glow-color);
text-shadow:
-1px 1px 0 var(--text-glow-color),
1px -1px 0 var(--text-glow-color),
-1px -1px 0 var(--text-glow-color),
1px 1px 0 var(--text-glow-color);
}
.label#internal .decal {
@ -834,16 +865,18 @@ label#internal {
.-striped {
--stripe-color: var(--danger-fill-color);
--stripe-size: 15px;
--glow-color: rgba(var(--danger-glow-rgb), .8);
--glow-color: rgba(var(--danger-glow-rgb), 0.8);
--glow-size: 3px;
background-image: repeating-linear-gradient(-45deg,
background-image: repeating-linear-gradient(
-45deg,
var(--glow-color) calc(-1 * var(--glow-size)),
var(--stripe-color) 0,
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
var(--glow-color) calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(var(--stripe-size) + var(--glow-size) / 2),
transparent calc(2 * var(--stripe-size)),
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size)));
var(--glow-color) calc(2 * var(--stripe-size) - var(--glow-size))
);
box-shadow: inset 0 0 1px calc(var(--glow-size) / 2) var(--shade-3);
}
@ -997,7 +1030,7 @@ label#internal {
@keyframes growAndFade {
0% {
opacity: .25;
opacity: 0.25;
transform: scale(0);
}
@ -1011,54 +1044,55 @@ label#internal {
17. HEXAGON BACKGROUND
========================= */
.main {
width: calc(max(120vh,120vw) + 100px);
width: calc(max(120vh, 120vw) + 100px);
margin-left: -35vh;
transform: translateY(min(-29vw,-40vw));
transform: translateY(min(-29vw, -40vw));
display: grid;
grid-template-columns: repeat(auto-fit,calc(var(--s) + 2*var(--mh)));
justify-content:center;
grid-template-columns: repeat(auto-fit, calc(var(--s) + 2 * var(--mh)));
justify-content: center;
--s: 80px; /* size */
--r: 1.15; /* ratio */
--h: 0.5;
--v: 0.25;
--hc:calc(clamp(0,var(--h),0.5) * var(--s)) ;
--vc:calc(clamp(0,var(--v),0.5) * var(--s) * var(--r));
--hc: calc(clamp(0, var(--h), 0.5) * var(--s));
--vc: calc(clamp(0, var(--v), 0.5) * var(--s) * var(--r));
--mv: 1px; /* vertical */
--mh: calc(var(--mv) + (var(--s) - 2*var(--hc))/2); /* horizontal */
--f: calc(2*var(--s)*var(--r) + 4*var(--mv) - 2*var(--vc) - 2px);
--mh: calc(var(--mv) + (var(--s) - 2 * var(--hc)) / 2); /* horizontal */
--f: calc(2 * var(--s) * var(--r) + 4 * var(--mv) - 2 * var(--vc) - 2px);
}
.hex-bg {
grid-column: 1/-1;
margin:0 auto;
margin: 0 auto;
font-size: 0;
position:relative;
position: relative;
}
.hex-bg div {
width: var(--s);
margin: var(--mv) var(--mh);
height: calc(var(--s)*var(--r));
height: calc(var(--s) * var(--r));
display: inline-block;
font-size:initial;
font-size: initial;
margin-bottom: calc(var(--mv) - var(--vc));
}
.hex-bg::before{
content: "";
width: calc(var(--s)/2 + var(--mh));
.hex-bg::before {
content: '';
width: calc(var(--s) / 2 + var(--mh));
float: left;
height: 100%;
shape-outside: repeating-linear-gradient(
transparent 0 calc(var(--f) - 2px),
#fff 0 var(--f));
#fff 0 var(--f)
);
}
.hex-bg div {
justify-content: center;
align-items: center;
font-weight:bold;
text-align:center;
font-weight: bold;
text-align: center;
}
.hex-bg div p {
@ -1076,23 +1110,23 @@ label#internal {
}
.hex-bg div::before {
position:absolute;
position: absolute;
display: flex;
}
.hex-bg div {
animation:showPopUp 0.3s ease-in-out forwards;
opacity:0;
animation: showPopUp 0.3s ease-in-out forwards;
opacity: 0;
transform: scale(0.5);
}
@keyframes show{
@keyframes show {
10% {
opacity:1;
opacity: 1;
transform: scale(1);
}
90% {
opacity:1;
opacity: 1;
transform: scale(1);
}
}
@ -1145,6 +1179,32 @@ label#internal {
font-family: 'Roboto Condensed', Arial, Helvetica, sans-serif;
}
/* =========================
21. RANDOM
========================= */
.animate-ping {
animation: ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
}
@keyframes ping {
0% {
transform: translate(-50%, 100%) scale(0.5);
opacity: 1;
}
75%,
100% {
transform: translate(-50%, 100%) scale(2);
opacity: 0;
}
}
/* .incident-logs-popup .mapboxgl-popup-content {
padding: 0;
overflow: hidden;
border-radius: 0.5rem;
} */
/* ==========================================================
END OF SIGAP UI CSS
==========================================================

View File

@ -97,16 +97,21 @@ export interface IDistanceResult {
export interface IIncidentLogs {
id: string;
user_id: string;
latitude: number;
longitude: number;
district: string;
address: string;
category: string;
source: string;
role_id: string;
name: string;
email: string;
phone: string;
avatar: string;
role: string;
description: string;
verified: boolean;
severity: "Low" | "Medium" | "High" | "Unknown";
longitude: number;
latitude: number;
timestamp: Date;
created_at: Date;
updated_at: Date;
category: string;
address: string;
district: string;
severity: "Low" | "Medium" | "High" | "Unknown";
source: string;
isVeryRecent?: boolean;
}

View File

@ -0,0 +1,101 @@
create or replace function public.nearby_units(
lat double precision,
lon double precision,
max_results integer default 5
)
returns table (
code_unit varchar,
name text,
type text,
address text,
district_id varchar,
lat_unit double precision,
lon_unit double precision,
distance_km double precision
)
language sql
as $$
select
u.code_unit,
u.name,
u.type,
u.address,
u.district_id,
gis.ST_Y(u.location::gis.geometry) as lat_unit,
gis.ST_X(u.location::gis.geometry) as lon_unit,
gis.ST_Distance(
u.location::gis.geography,
gis.ST_SetSRID(gis.ST_MakePoint(lon, lat), 4326)::gis.geography
) / 1000 as distance_km
from units u
order by gis.ST_Distance(
u.location::gis.geography,
gis.ST_SetSRID(gis.ST_MakePoint(lon, lat), 4326)::gis.geography
)
limit max_results
$$;
CREATE OR REPLACE FUNCTION public.update_location_distance_to_unit()
RETURNS TRIGGER AS $$
DECLARE
loc_lat FLOAT;
loc_lng FLOAT;
unit_lat FLOAT;
unit_lng FLOAT;
loc_point GEOGRAPHY;
unit_point GEOGRAPHY;
BEGIN
-- Ambil lat/lng dari location yang baru
SELECT gis.ST_Y(NEW.location::gis.geometry), gis.ST_X(NEW.location::gis.geometry)
INTO loc_lat, loc_lng;
-- Ambil lat/lng dari unit di distrik yang sama
SELECT gis.ST_Y(u.location::gis.geometry), gis.ST_X(u.location::gis.geometry)
INTO unit_lat, unit_lng
FROM units u
WHERE u.district_id = NEW.district_id
LIMIT 1;
-- Jika tidak ada unit di distrik yang sama, kembalikan NEW tanpa perubahan
IF unit_lat IS NULL OR unit_lng IS NULL THEN
RETURN NEW;
END IF;
-- Buat point geography dari lat/lng
loc_point := gis.ST_SetSRID(gis.ST_MakePoint(loc_lng, loc_lat), 4326)::gis.geography;
unit_point := gis.ST_SetSRID(gis.ST_MakePoint(unit_lng, unit_lat), 4326)::gis.geography;
-- Update jaraknya ke kolom distance_to_unit
NEW.distance_to_unit := gis.ST_Distance(loc_point, unit_point) / 1000;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Create the trigger
CREATE OR REPLACE TRIGGER update_location_distance_trigger
BEFORE INSERT OR UPDATE OF location, district_id
ON locations
FOR EACH ROW
EXECUTE FUNCTION public.update_location_distance_to_unit();
-- Spatial index untuk tabel units
CREATE INDEX IF NOT EXISTS idx_units_location_gist ON units USING GIST (location);
-- Spatial index untuk tabel locations
CREATE INDEX IF NOT EXISTS idx_locations_location_gist ON locations USING GIST (location);
-- Index untuk mempercepat pencarian units berdasarkan district_id
CREATE INDEX IF NOT EXISTS idx_units_district_id ON units (district_id);
-- Index untuk mempercepat pencarian locations berdasarkan district_id
CREATE INDEX IF NOT EXISTS idx_locations_district_id ON locations (district_id);
-- Index untuk kombinasi location dan district_id pada tabel units
CREATE INDEX IF NOT EXISTS idx_units_location_district ON units (district_id, location);
-- Analisis tabel setelah membuat index
ANALYZE units;
ANALYZE locations;