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:
parent
849b3c1ae3
commit
b9f69ade3b
|
@ -192,6 +192,27 @@ export async function getRecentIncidents(): Promise<IIncidentLogs[]> {
|
||||||
time: "desc",
|
time: "desc",
|
||||||
},
|
},
|
||||||
include: {
|
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: {
|
crime_categories: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
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
|
// Map DB result to IIncidentLogs interface
|
||||||
return incidents.map((incident) => ({
|
return incidents.map((incident) => ({
|
||||||
id: incident.id,
|
id: incident.id,
|
||||||
user_id: incident.user_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,
|
latitude: incident.locations?.latitude ?? null,
|
||||||
longitude: incident.locations?.longitude ?? null,
|
longitude: incident.locations?.longitude ?? null,
|
||||||
district: incident.locations.districts.name ?? "",
|
district: incident.locations.districts.name ?? "",
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useCallback, useRef } from "react"
|
import { useEffect, useCallback, useRef, useState } from "react"
|
||||||
import type { IIncidentLogs } from "@/app/_utils/types/crimes"
|
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 {
|
interface RecentIncidentsLayerProps {
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
|
@ -11,6 +13,8 @@ interface RecentIncidentsLayerProps {
|
||||||
|
|
||||||
export default function RecentIncidentsLayer({ visible = false, map, incidents = [] }: RecentIncidentsLayerProps) {
|
export default function RecentIncidentsLayer({ visible = false, map, incidents = [] }: RecentIncidentsLayerProps) {
|
||||||
const isInteractingWithMarker = useRef(false)
|
const isInteractingWithMarker = useRef(false)
|
||||||
|
const animationFrameRef = useRef<number | null>(null)
|
||||||
|
const [selectedIncident, setSelectedIncident] = useState<IIncidentLogs | null>(null)
|
||||||
|
|
||||||
// Filter incidents from the last 24 hours
|
// Filter incidents from the last 24 hours
|
||||||
const recentIncidents = incidents.filter((incident) => {
|
const recentIncidents = incidents.filter((incident) => {
|
||||||
|
@ -22,6 +26,9 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
|
||||||
return timeDiff <= 86400000
|
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(
|
const handleIncidentClick = useCallback(
|
||||||
(e: any) => {
|
(e: any) => {
|
||||||
if (!map) return
|
if (!map) return
|
||||||
|
@ -45,36 +52,36 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
|
||||||
id: incident.properties.id,
|
id: incident.properties.id,
|
||||||
description: incident.properties.description,
|
description: incident.properties.description,
|
||||||
status: incident.properties?.status || "Active",
|
status: incident.properties?.status || "Active",
|
||||||
|
verified: incident.properties?.status,
|
||||||
longitude: (incident.geometry as any).coordinates[0],
|
longitude: (incident.geometry as any).coordinates[0],
|
||||||
latitude: (incident.geometry as any).coordinates[1],
|
latitude: (incident.geometry as any).coordinates[1],
|
||||||
timestamp: new Date(incident.properties.timestamp || Date.now()),
|
timestamp: new Date(incident.properties.timestamp || Date.now()),
|
||||||
category: incident.properties.category,
|
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)
|
// Fly to the incident location
|
||||||
|
|
||||||
// Ensure markers stay visible
|
|
||||||
if (map.getLayer("recent-incidents")) {
|
|
||||||
map.setLayoutProperty("recent-incidents", "visibility", "visible")
|
|
||||||
}
|
|
||||||
|
|
||||||
// First fly to the incident location
|
|
||||||
map.flyTo({
|
map.flyTo({
|
||||||
center: [incidentDetails.longitude, incidentDetails.latitude],
|
center: [incidentDetails.longitude, incidentDetails.latitude],
|
||||||
zoom: 15,
|
zoom: ZOOM_3D,
|
||||||
bearing: 0,
|
bearing: BASE_BEARING,
|
||||||
pitch: 45,
|
pitch: BASE_PITCH,
|
||||||
duration: 2000,
|
duration: BASE_DURATION,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Dispatch the incident_click event to show the popup
|
// Set selected incident for the popup
|
||||||
const customEvent = new CustomEvent("incident_click", {
|
setSelectedIncident(incidentDetails)
|
||||||
detail: incidentDetails,
|
|
||||||
bubbles: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
map.getCanvas().dispatchEvent(customEvent)
|
|
||||||
document.dispatchEvent(customEvent)
|
|
||||||
|
|
||||||
// Reset the flag after a delay
|
// Reset the flag after a delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
@ -84,15 +91,25 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
|
||||||
[map],
|
[map],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Handle popup close
|
||||||
|
const handleClosePopup = useCallback(() => {
|
||||||
|
setSelectedIncident(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map || !visible) return
|
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 = {
|
const recentData = {
|
||||||
type: "FeatureCollection" as const,
|
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,
|
type: "Feature" as const,
|
||||||
geometry: {
|
geometry: {
|
||||||
type: "Point" as const,
|
type: "Point" as const,
|
||||||
|
@ -100,7 +117,13 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
id: incident.id,
|
id: incident.id,
|
||||||
|
role_id: incident.role_id,
|
||||||
user_id: incident.user_id,
|
user_id: incident.user_id,
|
||||||
|
name: incident.name,
|
||||||
|
email: incident.email,
|
||||||
|
telephone: incident.phone,
|
||||||
|
avatar: incident.avatar,
|
||||||
|
role: incident.role,
|
||||||
address: incident.address,
|
address: incident.address,
|
||||||
description: incident.description,
|
description: incident.description,
|
||||||
timestamp: incident.timestamp ? incident.timestamp.toString() : new Date().toString(),
|
timestamp: incident.timestamp ? incident.timestamp.toString() : new Date().toString(),
|
||||||
|
@ -109,8 +132,11 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
|
||||||
severity: incident.severity,
|
severity: incident.severity,
|
||||||
status: incident.verified,
|
status: incident.verified,
|
||||||
source: incident.source,
|
source: incident.source,
|
||||||
|
isVeryRecent: isVeryRecent, // Add this property to identify very recent incidents
|
||||||
|
timeDiff: timeDiff, // Time difference in milliseconds
|
||||||
},
|
},
|
||||||
})),
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
const setupLayerAndSource = () => {
|
const setupLayerAndSource = () => {
|
||||||
|
@ -136,31 +162,31 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if layer exists already
|
// Add the pulsing glow layer for very recent incidents (2 hours or less)
|
||||||
if (!map.getLayer("recent-incidents")) {
|
if (!map.getLayer("very-recent-incidents-pulse")) {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
id: "recent-incidents",
|
id: "very-recent-incidents-pulse",
|
||||||
type: "circle",
|
type: "circle",
|
||||||
source: "recent-incidents-source",
|
source: "recent-incidents-source",
|
||||||
|
filter: ["==", ["get", "isVeryRecent"], true],
|
||||||
paint: {
|
paint: {
|
||||||
"circle-color": "#FF5252", // Red color for recent incidents
|
"circle-color": "#FF0000",
|
||||||
"circle-radius": [
|
"circle-radius": [
|
||||||
"interpolate",
|
"interpolate",
|
||||||
["linear"],
|
["linear"],
|
||||||
["zoom"],
|
["zoom"],
|
||||||
7,
|
7,
|
||||||
4, // Slightly larger at lower zooms for visibility
|
|
||||||
12,
|
|
||||||
8,
|
8,
|
||||||
|
12,
|
||||||
|
16,
|
||||||
15,
|
15,
|
||||||
12, // Larger maximum size
|
24,
|
||||||
],
|
],
|
||||||
|
"circle-opacity": 0.3,
|
||||||
"circle-stroke-width": 2,
|
"circle-stroke-width": 2,
|
||||||
"circle-stroke-color": "#FFFFFF",
|
"circle-stroke-color": "#FF0000",
|
||||||
"circle-opacity": 0.8,
|
"circle-stroke-opacity": 0.5,
|
||||||
// Add a pulsing effect
|
|
||||||
"circle-stroke-opacity": ["interpolate", ["linear"], ["zoom"], 7, 0.5, 15, 0.8],
|
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
visibility: visible ? "visible" : "none",
|
visibility: visible ? "visible" : "none",
|
||||||
|
@ -168,8 +194,12 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
|
||||||
},
|
},
|
||||||
firstSymbolId,
|
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(
|
map.addLayer(
|
||||||
{
|
{
|
||||||
id: "recent-incidents-glow",
|
id: "recent-incidents-glow",
|
||||||
|
@ -185,7 +215,46 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
|
||||||
visibility: visible ? "visible" : "none",
|
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
|
// Add mouse events
|
||||||
|
@ -199,7 +268,42 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
|
||||||
} else {
|
} else {
|
||||||
// Update existing layer visibility
|
// Update existing layer visibility
|
||||||
map.setLayoutProperty("recent-incidents", "visibility", visible ? "visible" : "none")
|
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
|
// Ensure click handler is properly registered
|
||||||
|
@ -230,8 +334,30 @@ export default function RecentIncidentsLayer({ visible = false, map, incidents =
|
||||||
if (map) {
|
if (map) {
|
||||||
map.off("click", "recent-incidents", handleIncidentClick)
|
map.off("click", "recent-incidents", handleIncidentClick)
|
||||||
}
|
}
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [map, visible, recentIncidents, handleIncidentClick])
|
}, [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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,9 +4,10 @@ import { useEffect, useMemo, useState, useCallback } from "react"
|
||||||
import { Layer, Source } from "react-map-gl/mapbox"
|
import { Layer, Source } from "react-map-gl/mapbox"
|
||||||
import type { ICrimes } from "@/app/_utils/types/crimes"
|
import type { ICrimes } from "@/app/_utils/types/crimes"
|
||||||
import type mapboxgl from "mapbox-gl"
|
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 { calculateAverageTimeOfDay } from "@/app/_utils/time"
|
||||||
import TimelinePopup from "../pop-up/timeline-popup"
|
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 {
|
interface TimelineLayerProps {
|
||||||
crimes: ICrimes[]
|
crimes: ICrimes[]
|
||||||
|
@ -33,14 +34,31 @@ export default function TimelineLayer({
|
||||||
|
|
||||||
// Process district data to extract average incident times
|
// Process district data to extract average incident times
|
||||||
const districtTimeData = useMemo(() => {
|
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
|
// Group incidents by district
|
||||||
const districtGroups = new Map<
|
const districtGroups = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
districtId: string
|
districtId: string
|
||||||
districtName: string
|
districtName: string
|
||||||
incidents: Array<{ timestamp: Date; category: string }>
|
incidents: Array<{
|
||||||
|
timestamp: Date;
|
||||||
|
category: string;
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
}>
|
||||||
center: [number, number]
|
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
|
// Initialize district group if not exists
|
||||||
if (!districtGroups.has(crime.district_id)) {
|
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 centerIncident = crime.crime_incidents.find((inc) => inc.locations?.latitude && inc.locations?.longitude)
|
||||||
|
|
||||||
const center: [number, number] = centerIncident
|
const center: [number, number] = centerIncident
|
||||||
|
@ -60,22 +78,47 @@ export default function TimelineLayer({
|
||||||
districtId: crime.district_id,
|
districtId: crime.district_id,
|
||||||
districtName: crime.districts.name,
|
districtName: crime.districts.name,
|
||||||
incidents: [],
|
incidents: [],
|
||||||
|
filteredIncidents: [],
|
||||||
center,
|
center,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter incidents appropriately before adding
|
// Add all incidents first (for all-time stats)
|
||||||
crime.crime_incidents.forEach((incident) => {
|
crime.crime_incidents.forEach((incident) => {
|
||||||
// Skip invalid incidents
|
// Skip invalid incidents
|
||||||
if (!incident.timestamp) return
|
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)
|
const group = districtGroups.get(crime.district_id)
|
||||||
|
|
||||||
if (group) {
|
if (group) {
|
||||||
|
// Add to all incidents regardless of filters
|
||||||
group.incidents.push({
|
group.incidents.push({
|
||||||
timestamp: new Date(incident.timestamp),
|
timestamp: incidentDate,
|
||||||
category: incident.crime_categories.name,
|
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
|
// Calculate average time for each district
|
||||||
const result = Array.from(districtGroups.values())
|
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) => {
|
.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 {
|
return {
|
||||||
id: group.districtId,
|
id: group.districtId,
|
||||||
|
@ -95,23 +156,32 @@ export default function TimelineLayer({
|
||||||
avgMinute: avgTimeInfo.minute,
|
avgMinute: avgTimeInfo.minute,
|
||||||
formattedTime: avgTimeInfo.formattedTime,
|
formattedTime: avgTimeInfo.formattedTime,
|
||||||
timeDescription: avgTimeInfo.description,
|
timeDescription: avgTimeInfo.description,
|
||||||
totalIncidents: group.incidents.length,
|
totalIncidents: incidentsToUse.length,
|
||||||
timeOfDay: avgTimeInfo.timeOfDay,
|
timeOfDay: avgTimeInfo.timeOfDay,
|
||||||
earliestTime: format(avgTimeInfo.earliest, "p"),
|
earliestTime: format(avgTimeInfo.earliest, "p"),
|
||||||
latestTime: format(avgTimeInfo.latest, "p"),
|
latestTime: format(avgTimeInfo.latest, "p"),
|
||||||
mostFrequentHour: avgTimeInfo.mostFrequentHour,
|
mostFrequentHour: avgTimeInfo.mostFrequentHour,
|
||||||
categoryCounts: group.incidents.reduce(
|
categoryCounts: incidentsToUse.reduce(
|
||||||
(acc, inc) => {
|
(acc, inc) => {
|
||||||
acc[inc.category] = (acc[inc.category] || 0) + 1
|
acc[inc.category] = (acc[inc.category] || 0) + 1
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{} as Record<string, number>,
|
{} 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
|
return result
|
||||||
}, [crimes, filterCategory, year, month])
|
}, [crimes, filterCategory, year, month, useAllData])
|
||||||
|
|
||||||
// Convert processed data to GeoJSON for display
|
// Convert processed data to GeoJSON for display
|
||||||
const timelineGeoJSON = useMemo(() => {
|
const timelineGeoJSON = useMemo(() => {
|
||||||
|
@ -158,10 +228,10 @@ export default function TimelineLayer({
|
||||||
if (map) {
|
if (map) {
|
||||||
map.flyTo({
|
map.flyTo({
|
||||||
center: districtData.center,
|
center: districtData.center,
|
||||||
zoom: 12,
|
zoom: ZOOM_3D,
|
||||||
duration: 1000,
|
duration: BASE_DURATION,
|
||||||
pitch: 45,
|
pitch: BASE_PITCH,
|
||||||
bearing: 0,
|
bearing: BASE_BEARING,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -173,6 +243,14 @@ export default function TimelineLayer({
|
||||||
|
|
||||||
// Handle popup close
|
// Handle popup close
|
||||||
const handleClosePopup = useCallback(() => {
|
const handleClosePopup = useCallback(() => {
|
||||||
|
if (map) {
|
||||||
|
map.easeTo({
|
||||||
|
zoom: BASE_ZOOM,
|
||||||
|
duration: BASE_DURATION,
|
||||||
|
pitch: BASE_PITCH,
|
||||||
|
bearing: BASE_BEARING,
|
||||||
|
})
|
||||||
|
}
|
||||||
setSelectedDistrict(null)
|
setSelectedDistrict(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
@ -250,7 +328,13 @@ export default function TimelineLayer({
|
||||||
"#263238",
|
"#263238",
|
||||||
"#4CAF50", // Default color
|
"#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-width": 2,
|
||||||
"circle-stroke-color": "#000000",
|
"circle-stroke-color": "#000000",
|
||||||
"circle-opacity": 0.9,
|
"circle-opacity": 0.9,
|
||||||
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,7 +1,8 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
import { Popup } from "react-map-gl/mapbox"
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../ui/card"
|
||||||
import { Button } from "../../ui/button"
|
import { Button } from "../../ui/button"
|
||||||
|
@ -22,6 +23,20 @@ interface TimelinePopupProps {
|
||||||
mostFrequentHour: number
|
mostFrequentHour: number
|
||||||
categoryCounts: Record<string, number>
|
categoryCounts: Record<string, number>
|
||||||
timeOfDay: string
|
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,
|
onClose,
|
||||||
district,
|
district,
|
||||||
}: TimelinePopupProps) {
|
}: TimelinePopupProps) {
|
||||||
|
// Pagination state
|
||||||
|
const [currentPage, setCurrentPage] = useState(1)
|
||||||
|
const itemsPerPage = 3
|
||||||
|
|
||||||
// Get top 5 categories
|
// Get top 5 categories
|
||||||
const topCategories = Object.entries(district.categoryCounts)
|
const topCategories = Object.entries(district.categoryCounts)
|
||||||
.sort(([, countA], [, countB]) => countB - countA)
|
.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 (
|
return (
|
||||||
<Popup
|
<Popup
|
||||||
longitude={longitude}
|
longitude={longitude}
|
||||||
|
@ -76,8 +150,14 @@ export default function TimelinePopup({
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="text-xs">
|
<CardDescription className="text-xs flex items-center gap-1">
|
||||||
Average incident time analysis
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-3 pt-0">
|
<CardContent className="p-3 pt-0">
|
||||||
|
@ -88,8 +168,16 @@ export default function TimelinePopup({
|
||||||
{district.timeDescription}
|
{district.timeDescription}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
Based on {district.totalIncidents} incidents
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -104,8 +192,8 @@ export default function TimelinePopup({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border pt-2">
|
<div className="border-t border-border pt-2 mb-3">
|
||||||
<div className="text-xs font-medium mb-1">Top incident types:</div>
|
<div className={`${getTextColorForTimeOfDay(district.timeOfDay)} text-sm font-medium mb-1`}>Top incident types:</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{topCategories.map(([category, count]) => (
|
{topCategories.map(([category, count]) => (
|
||||||
<div key={category} className="flex justify-between">
|
<div key={category} className="flex justify-between">
|
||||||
|
@ -115,6 +203,56 @@ export default function TimelinePopup({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -61,7 +61,7 @@ export default function UnitPopup({
|
||||||
closeButton={false}
|
closeButton={false}
|
||||||
closeOnClick={false}
|
closeOnClick={false}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
anchor="top"
|
anchor="bottom"
|
||||||
maxWidth="320px"
|
maxWidth="320px"
|
||||||
className="unit-popup z-50"
|
className="unit-popup z-50"
|
||||||
>
|
>
|
||||||
|
|
|
@ -25,9 +25,15 @@
|
||||||
/* =========================
|
/* =========================
|
||||||
2. COLOR UTILITY CLASSES
|
2. COLOR UTILITY CLASSES
|
||||||
========================= */
|
========================= */
|
||||||
.red-color { color: var(--red); }
|
.red-color {
|
||||||
.red-bg { background-color: var(--red); }
|
color: var(--red);
|
||||||
.red-border { border: 1px solid var(--red); }
|
}
|
||||||
|
.red-bg {
|
||||||
|
background-color: var(--red);
|
||||||
|
}
|
||||||
|
.red-border {
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
3. STRIP BAR & ANIMATIONS
|
3. STRIP BAR & ANIMATIONS
|
||||||
|
@ -41,16 +47,18 @@
|
||||||
|
|
||||||
--stripe-color: var(--orange);
|
--stripe-color: var(--orange);
|
||||||
--stripe-size: 15px;
|
--stripe-size: 15px;
|
||||||
--glow-color: rgba(255, 94, 0, .8);
|
--glow-color: rgba(255, 94, 0, 0.8);
|
||||||
--glow-size: 3px;
|
--glow-size: 3px;
|
||||||
background-image: repeating-linear-gradient(-45deg,
|
background-image: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
var(--glow-color) calc(-1 * var(--glow-size)),
|
var(--glow-color) calc(-1 * var(--glow-size)),
|
||||||
var(--stripe-color) 0,
|
var(--stripe-color) 0,
|
||||||
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
|
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
|
||||||
var(--glow-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(var(--stripe-size) + var(--glow-size) / 2),
|
||||||
transparent calc(2 * var(--stripe-size)),
|
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 --- */
|
/* --- Red Stripe --- */
|
||||||
|
@ -64,14 +72,16 @@
|
||||||
--stripe-size: 15px;
|
--stripe-size: 15px;
|
||||||
--glow-color: rgba(255, 17, 0, 0.8);
|
--glow-color: rgba(255, 17, 0, 0.8);
|
||||||
--glow-size: 3px;
|
--glow-size: 3px;
|
||||||
background-image: repeating-linear-gradient(-45deg,
|
background-image: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
var(--glow-color) calc(-1 * var(--glow-size)),
|
var(--glow-color) calc(-1 * var(--glow-size)),
|
||||||
var(--stripe-color) 0,
|
var(--stripe-color) 0,
|
||||||
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
|
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
|
||||||
var(--glow-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(var(--stripe-size) + var(--glow-size) / 2),
|
||||||
transparent calc(2 * var(--stripe-size)),
|
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 --- */
|
/* --- Vertical Orange Stripe --- */
|
||||||
|
@ -80,16 +90,18 @@
|
||||||
transform: translate3d(0, 0, 0);
|
transform: translate3d(0, 0, 0);
|
||||||
--stripe-color: var(--orange);
|
--stripe-color: var(--orange);
|
||||||
--stripe-size: 15px;
|
--stripe-size: 15px;
|
||||||
--glow-color: rgba(255, 94, 0, .8);
|
--glow-color: rgba(255, 94, 0, 0.8);
|
||||||
--glow-size: 3px;
|
--glow-size: 3px;
|
||||||
background-image: repeating-linear-gradient(45deg,
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
var(--glow-color) calc(-1 * var(--glow-size)),
|
var(--glow-color) calc(-1 * var(--glow-size)),
|
||||||
var(--stripe-color) 0,
|
var(--stripe-color) 0,
|
||||||
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
|
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
|
||||||
var(--glow-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(var(--stripe-size) + var(--glow-size) / 2),
|
||||||
transparent calc(2 * var(--stripe-size)),
|
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 --- */
|
/* --- Vertical Red Stripe --- */
|
||||||
|
@ -100,20 +112,26 @@
|
||||||
--stripe-size: 15px;
|
--stripe-size: 15px;
|
||||||
--glow-color: rgba(255, 17, 0, 0.8);
|
--glow-color: rgba(255, 17, 0, 0.8);
|
||||||
--glow-size: 3px;
|
--glow-size: 3px;
|
||||||
background-image: repeating-linear-gradient(45deg,
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
var(--glow-color) calc(-1 * var(--glow-size)),
|
var(--glow-color) calc(-1 * var(--glow-size)),
|
||||||
var(--stripe-color) 0,
|
var(--stripe-color) 0,
|
||||||
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
|
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
|
||||||
var(--glow-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(var(--stripe-size) + var(--glow-size) / 2),
|
||||||
transparent calc(2 * var(--stripe-size)),
|
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 --- */
|
/* --- Animations for Stripe --- */
|
||||||
@keyframes slideinBg {
|
@keyframes slideinBg {
|
||||||
from {background-position: top; }
|
from {
|
||||||
to {background-position: -100px 0px;}
|
background-position: top;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: -100px 0px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.strip-animation-vertical {
|
.strip-animation-vertical {
|
||||||
|
@ -352,7 +370,6 @@
|
||||||
|
|
||||||
.mapboxgl-popup {
|
.mapboxgl-popup {
|
||||||
width: auto;
|
width: auto;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
|
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
|
||||||
|
@ -593,7 +610,7 @@
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
--text-glow-color: rgba(var(--glow-rgb), .5);
|
--text-glow-color: rgba(var(--glow-rgb), 0.5);
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -614,7 +631,9 @@
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
border-color: unset;
|
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 {
|
.red-bordered {
|
||||||
|
@ -624,7 +643,9 @@
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
border-color: unset;
|
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 {
|
.red-bordered-bottom {
|
||||||
|
@ -632,7 +653,9 @@
|
||||||
--border-glow-color: rgba(var(--danger-glow-rgb), 0.7);
|
--border-glow-color: rgba(var(--danger-glow-rgb), 0.7);
|
||||||
border-color: unset;
|
border-color: unset;
|
||||||
border-bottom: 1px solid red;
|
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 {
|
.red-bordered-top {
|
||||||
|
@ -640,7 +663,9 @@
|
||||||
--border-glow-color: rgba(var(--danger-glow-rgb), 0.7);
|
--border-glow-color: rgba(var(--danger-glow-rgb), 0.7);
|
||||||
border-color: unset;
|
border-color: unset;
|
||||||
border-top: 1px solid var(--danger-glow-rgb);
|
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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.jajar-genjang .time-countdown {}
|
.jajar-genjang .time-countdown {
|
||||||
|
}
|
||||||
|
|
||||||
.jajar-genjang.danger {
|
.jajar-genjang.danger {
|
||||||
background-color: var(--red);
|
background-color: var(--red);
|
||||||
|
@ -739,7 +765,8 @@
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-daerah.danger {}
|
.item-daerah.danger {
|
||||||
|
}
|
||||||
|
|
||||||
.item-daerah .content {
|
.item-daerah .content {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
@ -819,9 +846,13 @@ label#internal {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
--text-glow-color: rgba(var(--glow-rgb), .5);
|
--text-glow-color: rgba(var(--glow-rgb), 0.5);
|
||||||
color: var(--text-color);
|
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 {
|
.label#internal .decal {
|
||||||
|
@ -834,16 +865,18 @@ label#internal {
|
||||||
.-striped {
|
.-striped {
|
||||||
--stripe-color: var(--danger-fill-color);
|
--stripe-color: var(--danger-fill-color);
|
||||||
--stripe-size: 15px;
|
--stripe-size: 15px;
|
||||||
--glow-color: rgba(var(--danger-glow-rgb), .8);
|
--glow-color: rgba(var(--danger-glow-rgb), 0.8);
|
||||||
--glow-size: 3px;
|
--glow-size: 3px;
|
||||||
background-image: repeating-linear-gradient(-45deg,
|
background-image: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
var(--glow-color) calc(-1 * var(--glow-size)),
|
var(--glow-color) calc(-1 * var(--glow-size)),
|
||||||
var(--stripe-color) 0,
|
var(--stripe-color) 0,
|
||||||
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
|
var(--stripe-color) calc(var(--stripe-size) - var(--glow-size) / 2),
|
||||||
var(--glow-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(var(--stripe-size) + var(--glow-size) / 2),
|
||||||
transparent calc(2 * var(--stripe-size)),
|
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);
|
box-shadow: inset 0 0 1px calc(var(--glow-size) / 2) var(--shade-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -997,7 +1030,7 @@ label#internal {
|
||||||
|
|
||||||
@keyframes growAndFade {
|
@keyframes growAndFade {
|
||||||
0% {
|
0% {
|
||||||
opacity: .25;
|
opacity: 0.25;
|
||||||
transform: scale(0);
|
transform: scale(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1045,13 +1078,14 @@ label#internal {
|
||||||
}
|
}
|
||||||
|
|
||||||
.hex-bg::before {
|
.hex-bg::before {
|
||||||
content: "";
|
content: '';
|
||||||
width: calc(var(--s) / 2 + var(--mh));
|
width: calc(var(--s) / 2 + var(--mh));
|
||||||
float: left;
|
float: left;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
shape-outside: repeating-linear-gradient(
|
shape-outside: repeating-linear-gradient(
|
||||||
transparent 0 calc(var(--f) - 2px),
|
transparent 0 calc(var(--f) - 2px),
|
||||||
#fff 0 var(--f));
|
#fff 0 var(--f)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hex-bg div {
|
.hex-bg div {
|
||||||
|
@ -1145,6 +1179,32 @@ label#internal {
|
||||||
font-family: 'Roboto Condensed', Arial, Helvetica, sans-serif;
|
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
|
END OF SIGAP UI CSS
|
||||||
==========================================================
|
==========================================================
|
||||||
|
|
|
@ -97,16 +97,21 @@ export interface IDistanceResult {
|
||||||
export interface IIncidentLogs {
|
export interface IIncidentLogs {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
latitude: number;
|
role_id: string;
|
||||||
longitude: number;
|
name: string;
|
||||||
district: string;
|
email: string;
|
||||||
address: string;
|
phone: string;
|
||||||
category: string;
|
avatar: string;
|
||||||
source: string;
|
role: string;
|
||||||
description: string;
|
description: string;
|
||||||
verified: boolean;
|
verified: boolean;
|
||||||
severity: "Low" | "Medium" | "High" | "Unknown";
|
longitude: number;
|
||||||
|
latitude: number;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
created_at: Date;
|
category: string;
|
||||||
updated_at: Date;
|
address: string;
|
||||||
|
district: string;
|
||||||
|
severity: "Low" | "Medium" | "High" | "Unknown";
|
||||||
|
source: string;
|
||||||
|
isVeryRecent?: boolean;
|
||||||
}
|
}
|
|
@ -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;
|
Loading…
Reference in New Issue