feat: Add Panic Button Demo component and EWS Alert Layer for incident management
- Implemented PanicButtonDemo component for triggering alerts with varying priorities. - Created EWSAlertLayer to manage and display active incidents on the map. - Added styles for alert markers and status indicators. - Developed mock data utilities for generating and managing incident logs. - Defined TypeScript interfaces for incident logs and locations.
This commit is contained in:
parent
3448945f4d
commit
8a1a4320c2
|
@ -1,7 +1,6 @@
|
|||
import { Geist } from "next/font/google";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import "@/app/_styles/globals.css";
|
||||
import "@/app/_styles/ui.css";
|
||||
import ReactQueryProvider from "@/app/_lib/react-query-provider";
|
||||
import { Toaster } from "@/app/_components/ui/sonner";
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,108 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/app/_components/ui/button';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
ShieldAlert,
|
||||
Radio,
|
||||
RadioTower,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/app/_lib/utils';
|
||||
import { Badge } from '@/app/_components/ui/badge';
|
||||
import { IIncidentLog } from '@/app/_utils/types/ews';
|
||||
|
||||
interface PanicButtonDemoProps {
|
||||
onTriggerAlert: (priority: 'high' | 'medium' | 'low') => void;
|
||||
onResolveAllAlerts: () => void;
|
||||
activeIncidents: IIncidentLog[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PanicButtonDemo({
|
||||
onTriggerAlert,
|
||||
onResolveAllAlerts,
|
||||
activeIncidents,
|
||||
className
|
||||
}: PanicButtonDemoProps) {
|
||||
const [isTriggering, setIsTriggering] = useState(false);
|
||||
|
||||
const handleTriggerPanic = (priority: 'high' | 'medium' | 'low') => {
|
||||
setIsTriggering(true);
|
||||
onTriggerAlert(priority);
|
||||
|
||||
// Reset animation
|
||||
setTimeout(() => {
|
||||
setIsTriggering(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("border border-muted bg-background p-3 rounded-lg shadow-xl", className)}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-bold flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-red-500" />
|
||||
<span>EWS Panic Button Demo</span>
|
||||
</h3>
|
||||
|
||||
{activeIncidents.length > 0 && (
|
||||
<Badge variant="destructive" className="ml-2 animate-pulse">
|
||||
{activeIncidents.length} Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
className={cn(
|
||||
"bg-red-600 hover:bg-red-700 flex items-center gap-2 transition-all",
|
||||
isTriggering && "animate-ping"
|
||||
)}
|
||||
onClick={() => handleTriggerPanic('high')}
|
||||
>
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
<span>SEND HIGH PRIORITY ALERT</span>
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
className="bg-amber-600 hover:bg-amber-700 flex items-center gap-2"
|
||||
onClick={() => handleTriggerPanic('medium')}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
<span>Medium Priority</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-blue-600 text-blue-600 hover:bg-blue-100 flex items-center gap-2"
|
||||
onClick={() => handleTriggerPanic('low')}
|
||||
>
|
||||
<RadioTower className="h-4 w-4" />
|
||||
<span>Low Priority</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{activeIncidents.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-3 border-green-600 text-green-600 hover:bg-green-100"
|
||||
onClick={onResolveAllAlerts}
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
<span>Resolve All Alerts</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Simulates a mobile app panic button activation in the Jember area.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -42,6 +42,7 @@ export default function CrimeMap() {
|
|||
const [showUnclustered, setShowUnclustered] = useState(true)
|
||||
const [useAllYears, setUseAllYears] = useState<boolean>(false)
|
||||
const [useAllMonths, setUseAllMonths] = useState<boolean>(false)
|
||||
const [showEWS, setShowEWS] = useState<boolean>(true)
|
||||
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
|
@ -185,6 +186,9 @@ export default function CrimeMap() {
|
|||
setUseAllYears(false);
|
||||
setUseAllMonths(false);
|
||||
}
|
||||
|
||||
// Enable EWS in all modes for demo purposes
|
||||
setShowEWS(true);
|
||||
}
|
||||
|
||||
const showTimelineLayer = activeControl === "timeline";
|
||||
|
@ -218,11 +222,12 @@ export default function CrimeMap() {
|
|||
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mapbox-container relative h-[600px]" ref={mapContainerRef}>
|
||||
<div className="mapbox-container overlay-bg vertical-reveal relative h-[600px]" ref={mapContainerRef}>
|
||||
<div className={cn(
|
||||
"transition-all duration-300 ease-in-out",
|
||||
!sidebarCollapsed && isFullscreen && "ml-[400px]"
|
||||
)}>
|
||||
<div className="">
|
||||
<MapView mapStyle="mapbox://styles/mapbox/dark-v11" className="h-[600px] w-full rounded-md">
|
||||
<Layers
|
||||
crimes={filteredCrimes || []}
|
||||
|
@ -232,6 +237,7 @@ export default function CrimeMap() {
|
|||
filterCategory={selectedCategory}
|
||||
activeControl={activeControl}
|
||||
useAllData={useAllYears}
|
||||
showEWS={showEWS}
|
||||
/>
|
||||
|
||||
{isFullscreen && (
|
||||
|
@ -301,6 +307,7 @@ export default function CrimeMap() {
|
|||
</MapView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
@ -14,19 +14,28 @@ export default function CoastlineLayer({ map, visible = true }: CoastlineLayerPr
|
|||
|
||||
// Function to add coastline layer
|
||||
function addCoastline() {
|
||||
const sourceId = 'coastline';
|
||||
const sourceId = 'coastline_id';
|
||||
const layerId = 'outline-coastline';
|
||||
|
||||
// Make sure map is defined
|
||||
if (!map) return;
|
||||
|
||||
try {
|
||||
// Check if the source already exists
|
||||
if (!map.getSource(sourceId)) {
|
||||
// More robust check if the source already exists
|
||||
let sourceExists = false;
|
||||
try {
|
||||
sourceExists = !!map.getSource(sourceId);
|
||||
} catch (e) {
|
||||
sourceExists = false;
|
||||
}
|
||||
|
||||
if (!sourceExists) {
|
||||
// Add coastline data source
|
||||
fetch('/geojson/garis_pantai.geojson')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Double-check the source doesn't exist right before adding
|
||||
if (!map.getSource(sourceId)) {
|
||||
// Add coastline data source
|
||||
map.addSource(sourceId, {
|
||||
type: 'geojson',
|
||||
|
@ -43,11 +52,12 @@ export default function CoastlineLayer({ map, visible = true }: CoastlineLayerPr
|
|||
'visibility': visible ? 'visible' : 'none',
|
||||
},
|
||||
'paint': {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-color': '#1a1a1a', // dull white color instead of ['get', 'color']
|
||||
'line-width': 5,
|
||||
'line-opacity': 1
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error fetching coastline data:', error);
|
||||
|
@ -71,7 +81,7 @@ export default function CoastlineLayer({ map, visible = true }: CoastlineLayerPr
|
|||
if (!map || !map.getStyle()) return;
|
||||
|
||||
const layerId = 'outline-coastline';
|
||||
const sourceId = 'coastline';
|
||||
const sourceId = 'coastline_id';
|
||||
|
||||
if (map.getLayer(layerId)) {
|
||||
map.removeLayer(layerId);
|
||||
|
@ -92,14 +102,24 @@ export default function CoastlineLayer({ map, visible = true }: CoastlineLayerPr
|
|||
if (map.loaded() && map.isStyleLoaded()) {
|
||||
addCoastline();
|
||||
} else {
|
||||
// Use multiple events to catch map ready state
|
||||
map.on('load', addCoastline);
|
||||
map.on('style.load', addCoastline);
|
||||
map.on('styledata', addCoastline);
|
||||
// Reduce event listeners to minimize duplicates
|
||||
const addLayerOnce = () => {
|
||||
// Remove all listeners after first successful execution
|
||||
map.off('load', addLayerOnce);
|
||||
map.off('style.load', addLayerOnce);
|
||||
map.off('styledata', addLayerOnce);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
addCoastline();
|
||||
};
|
||||
|
||||
map.on('load', addLayerOnce);
|
||||
map.on('style.load', addLayerOnce);
|
||||
map.on('styledata', addLayerOnce);
|
||||
|
||||
// Fallback timeout
|
||||
timeoutId = setTimeout(() => {
|
||||
addCoastline();
|
||||
addLayerOnce();
|
||||
}, 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
@ -0,0 +1,318 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import { IIncidentLog, EWSStatus } from '@/app/_utils/types/ews';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import DigitalClock from '../markers/digital-clock';
|
||||
import { Badge } from '@/app/_components/ui/badge';
|
||||
import { Button } from '@/app/_components/ui/button';
|
||||
|
||||
interface EWSAlertLayerProps {
|
||||
map: mapboxgl.Map | null;
|
||||
incidents?: IIncidentLog[];
|
||||
onIncidentResolved?: (id: string) => void;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export default function EWSAlertLayer({
|
||||
map,
|
||||
incidents = [],
|
||||
onIncidentResolved,
|
||||
visible = true
|
||||
}: EWSAlertLayerProps) {
|
||||
const [ewsStatus, setEwsStatus] = useState<EWSStatus>('idle');
|
||||
const [activeIncidents, setActiveIncidents] = useState<IIncidentLog[]>([]);
|
||||
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map());
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const alertAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const pulsingDotsRef = useRef<Record<string, HTMLDivElement>>({}); // For animation reference
|
||||
|
||||
// Initialize audio
|
||||
useEffect(() => {
|
||||
try {
|
||||
// Try multiple possible audio sources in order of preference
|
||||
const possibleSources = [
|
||||
'/sounds/alert.mp3',
|
||||
'/alert.mp3',
|
||||
'/sounds/error.mp3',
|
||||
'/error.mp3',
|
||||
'/sounds/notification.mp3'
|
||||
];
|
||||
|
||||
// Try to load the first audio source
|
||||
alertAudioRef.current = new Audio("/sounds/error-2-126514.mp3");
|
||||
|
||||
// Add error handling to try alternative sources
|
||||
alertAudioRef.current.addEventListener('error', (e) => {
|
||||
// console.warn(`Failed to load audio from ${possibleSources[0]}, trying fallback sources`, e);
|
||||
|
||||
// Try each source in succession
|
||||
// for (let i = 1; i < possibleSources.length; i++) {
|
||||
// try {
|
||||
// alertAudioRef.current = new Audio(possibleSources[i]);
|
||||
// console.log(`Using fallback audio source: ${possibleSources[i]}`);
|
||||
// break;
|
||||
// } catch (err) {
|
||||
// console.warn(`Fallback audio ${possibleSources[i]} also failed`, err);
|
||||
// }
|
||||
// }
|
||||
});
|
||||
|
||||
alertAudioRef.current.volume = 0.5;
|
||||
|
||||
// Loop handling - stop after 1 minute
|
||||
let loopStartTime: number | null = null;
|
||||
|
||||
alertAudioRef.current.addEventListener('ended', () => {
|
||||
// Initialize start time on first play
|
||||
if (loopStartTime === null) {
|
||||
loopStartTime = Date.now();
|
||||
}
|
||||
|
||||
// Check if 1 minute has passed
|
||||
if (Date.now() - loopStartTime < 60000) { // 60000ms = 1 minute
|
||||
alertAudioRef.current?.play().catch(err =>
|
||||
console.error("Error playing looped alert sound:", err));
|
||||
} else {
|
||||
loopStartTime = null; // Reset for future alerts
|
||||
}
|
||||
});
|
||||
|
||||
// Preload the audio
|
||||
alertAudioRef.current.load();
|
||||
} catch (err) {
|
||||
console.error("Could not initialize alert audio:", err);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (alertAudioRef.current) {
|
||||
alertAudioRef.current.pause();
|
||||
alertAudioRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update active incidents when incidents prop changes
|
||||
useEffect(() => {
|
||||
const newActiveIncidents = incidents.filter(inc => inc.status === 'active');
|
||||
setActiveIncidents(newActiveIncidents);
|
||||
|
||||
// Update EWS status
|
||||
if (newActiveIncidents.length > 0) {
|
||||
setEwsStatus('alert');
|
||||
// Play alert sound with error handling
|
||||
if (alertAudioRef.current) {
|
||||
alertAudioRef.current.play()
|
||||
.catch(err => {
|
||||
console.error("Error playing alert sound:", err);
|
||||
// Create a simple beep as fallback
|
||||
try {
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.setValueAtTime(800, audioContext.currentTime);
|
||||
oscillator.connect(audioContext.destination);
|
||||
oscillator.start();
|
||||
oscillator.stop(audioContext.currentTime + 0.5);
|
||||
} catch (fallbackErr) {
|
||||
console.error("Fallback audio also failed:", fallbackErr);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setEwsStatus('idle');
|
||||
}
|
||||
}, [incidents]);
|
||||
|
||||
// Handle marker creation, animation, and cleanup
|
||||
useEffect(() => {
|
||||
if (!map || !visible) return;
|
||||
|
||||
// Clear any existing markers
|
||||
markersRef.current.forEach(marker => marker.remove());
|
||||
markersRef.current.clear();
|
||||
pulsingDotsRef.current = {};
|
||||
|
||||
// Cancel any ongoing animations
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
|
||||
// Create new markers for all active incidents
|
||||
activeIncidents.forEach(incident => {
|
||||
// Don't add if marker already exists
|
||||
if (markersRef.current.has(incident.id)) return;
|
||||
|
||||
const { latitude, longitude } = incident.location;
|
||||
|
||||
// Create marker element
|
||||
const el = document.createElement('div');
|
||||
el.className = 'ews-alert-marker';
|
||||
|
||||
// Create a wrapper for the pulsing effect
|
||||
const pulsingDot = document.createElement('div');
|
||||
pulsingDot.className = 'pulsing-dot';
|
||||
pulsingDotsRef.current[incident.id] = pulsingDot;
|
||||
|
||||
// Create the content for the marker
|
||||
const contentElement = document.createElement('div');
|
||||
contentElement.className = 'ews-alert-content';
|
||||
|
||||
// Use React for the popup content
|
||||
const root = createRoot(contentElement);
|
||||
root.render(
|
||||
<div className="relative">
|
||||
<div className="absolute z-50 -top-4 -left-4 w-8 h-8 rounded-full bg-red-600 animate-ping" />
|
||||
<div className="relative z-40 bg-red-600 text-white p-2 rounded-lg shadow-lg min-w-[200px] max-w-[300px]">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<Badge variant="outline" className={`
|
||||
${incident.priority === 'high' ? 'bg-red-700 text-white' :
|
||||
incident.priority === 'medium' ? 'bg-amber-600 text-white' :
|
||||
'bg-blue-600 text-white'}
|
||||
`}>
|
||||
{incident.priority.toUpperCase()} PRIORITY
|
||||
</Badge>
|
||||
<div className="text-xs">
|
||||
<DigitalClock
|
||||
timeZone="Asia/Jakarta"
|
||||
format="24h"
|
||||
showSeconds={true}
|
||||
className="font-mono bg-black/50 px-1 rounded text-red-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-bold flex items-center gap-1">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{incident.category || "Emergency Alert"}
|
||||
</h3>
|
||||
|
||||
<div className="text-sm mt-1">
|
||||
<p className="font-bold">{incident.location.district}</p>
|
||||
<p className="text-xs">{incident.location.address}</p>
|
||||
<p className="text-xs mt-1">Reported by: {incident.reporter.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between mt-2">
|
||||
<Badge variant="secondary" className="bg-red-800 text-white">
|
||||
ID: {incident.id}
|
||||
</Badge>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-green-700 text-white border-0 hover:bg-green-600 h-6 text-xs px-2"
|
||||
onClick={() => onIncidentResolved?.(incident.id)}
|
||||
>
|
||||
Respond
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Add the elements to the marker
|
||||
el.appendChild(pulsingDot);
|
||||
el.appendChild(contentElement);
|
||||
|
||||
// Create and add the marker
|
||||
const marker = new mapboxgl.Marker({
|
||||
element: el,
|
||||
anchor: 'center'
|
||||
})
|
||||
.setLngLat([longitude, latitude])
|
||||
.addTo(map);
|
||||
|
||||
markersRef.current.set(incident.id, marker);
|
||||
|
||||
// Fly to the incident if it's new
|
||||
const isNewIncident = activeIncidents.length > 0 &&
|
||||
incident.id === activeIncidents[activeIncidents.length - 1].id;
|
||||
|
||||
if (isNewIncident) {
|
||||
// Dispatch custom flyTo event
|
||||
const flyToEvent = new CustomEvent('mapbox_fly_to', {
|
||||
detail: {
|
||||
longitude,
|
||||
latitude,
|
||||
zoom: 15,
|
||||
bearing: 0,
|
||||
pitch: 60,
|
||||
duration: 2000
|
||||
}
|
||||
});
|
||||
|
||||
map.getContainer().dispatchEvent(flyToEvent);
|
||||
}
|
||||
});
|
||||
|
||||
// Setup animation for pulsing dots
|
||||
const animatePulsingDots = () => {
|
||||
Object.entries(pulsingDotsRef.current).forEach(([id, el]) => {
|
||||
const scale = 1 + 0.5 * Math.sin(Date.now() / 200); // Pulsing effect
|
||||
el.style.transform = `scale(${scale})`;
|
||||
|
||||
// Add rotation for more visual effect
|
||||
const rotation = (Date.now() / 50) % 360;
|
||||
el.style.transform += ` rotate(${rotation}deg)`;
|
||||
});
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animatePulsingDots);
|
||||
};
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animatePulsingDots);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (animationFrameRef.current !== null) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
markersRef.current.forEach(marker => marker.remove());
|
||||
markersRef.current.clear();
|
||||
};
|
||||
}, [map, activeIncidents, visible, onIncidentResolved]);
|
||||
|
||||
// Create a floating EWS status indicator when in alert mode
|
||||
useEffect(() => {
|
||||
if (!map || ewsStatus === 'idle') return;
|
||||
|
||||
// Create status indicator element if it doesn't exist
|
||||
let statusContainer = document.getElementById('ews-status-indicator');
|
||||
|
||||
if (!statusContainer) {
|
||||
statusContainer = document.createElement('div');
|
||||
statusContainer.id = 'ews-status-indicator';
|
||||
statusContainer.className = 'absolute top-16 left-1/2 transform -translate-x-1/2 z-50';
|
||||
map.getContainer().appendChild(statusContainer);
|
||||
|
||||
// Use React for the status indicator
|
||||
const root = createRoot(statusContainer);
|
||||
root.render(
|
||||
<div className={`
|
||||
py-2 px-4 rounded-full shadow-lg flex items-center gap-2
|
||||
${ewsStatus === 'alert' ? 'bg-red-600 text-white' : 'bg-amber-500 text-white'}
|
||||
animate-pulse
|
||||
`}>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="font-bold uppercase tracking-wider">
|
||||
{ewsStatus === 'alert'
|
||||
? `Alert: ${activeIncidents.length} Active Emergencies`
|
||||
: 'System Active'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (statusContainer && statusContainer.parentNode) {
|
||||
statusContainer.parentNode.removeChild(statusContainer);
|
||||
}
|
||||
};
|
||||
}, [map, ewsStatus, activeIncidents.length]);
|
||||
|
||||
return null; // This component doesn't render anything directly
|
||||
}
|
|
@ -23,6 +23,12 @@ import CrimePopup from "../pop-up/crime-popup"
|
|||
import TimeZonesDisplay from "./timezone"
|
||||
import TimezoneLayer from "./timezone"
|
||||
import FaultLinesLayer from "./fault-lines"
|
||||
import CoastlineLayer from "./coastline"
|
||||
import EWSAlertLayer from "./ews-alert-layer"
|
||||
import PanicButtonDemo from "../controls/panic-button-demo"
|
||||
|
||||
import { IIncidentLog } from "@/app/_utils/types/ews"
|
||||
import { addMockIncident, getAllIncidents, resolveIncident } from "@/app/_utils/mock/ews-data"
|
||||
|
||||
// Interface for crime incident
|
||||
interface ICrimeIncident {
|
||||
|
@ -67,6 +73,7 @@ interface LayersProps {
|
|||
activeControl: ITooltips
|
||||
tilesetId?: string
|
||||
useAllData?: boolean
|
||||
showEWS?: boolean
|
||||
}
|
||||
|
||||
export default function Layers({
|
||||
|
@ -79,6 +86,7 @@ export default function Layers({
|
|||
activeControl,
|
||||
tilesetId = MAPBOX_TILESET_ID,
|
||||
useAllData = false,
|
||||
showEWS = true,
|
||||
}: LayersProps) {
|
||||
const { current: map } = useMap()
|
||||
|
||||
|
@ -96,25 +104,47 @@ export default function Layers({
|
|||
|
||||
const crimeDataByDistrict = processCrimeDataByDistrict(crimes)
|
||||
|
||||
// Handle popup close with a common reset pattern
|
||||
const [ewsIncidents, setEwsIncidents] = useState<IIncidentLog[]>([]);
|
||||
const [showPanicDemo, setShowPanicDemo] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setEwsIncidents(getAllIncidents());
|
||||
}, []);
|
||||
|
||||
const handleTriggerAlert = useCallback((priority: 'high' | 'medium' | 'low') => {
|
||||
const newIncident = addMockIncident({ priority });
|
||||
setEwsIncidents(getAllIncidents());
|
||||
}, []);
|
||||
|
||||
const handleResolveIncident = useCallback((id: string) => {
|
||||
resolveIncident(id);
|
||||
setEwsIncidents(getAllIncidents());
|
||||
}, []);
|
||||
|
||||
const handleResolveAllAlerts = useCallback(() => {
|
||||
ewsIncidents.forEach(incident => {
|
||||
if (incident.status === 'active') {
|
||||
resolveIncident(incident.id);
|
||||
}
|
||||
});
|
||||
setEwsIncidents(getAllIncidents());
|
||||
}, [ewsIncidents]);
|
||||
|
||||
const handlePopupClose = useCallback(() => {
|
||||
// Reset selected state
|
||||
selectedDistrictRef.current = null
|
||||
setSelectedDistrict(null)
|
||||
setSelectedIncident(null)
|
||||
setFocusedDistrictId(null)
|
||||
|
||||
// Reset map view/camera
|
||||
if (map) {
|
||||
map.easeTo({
|
||||
zoom: BASE_ZOOM,
|
||||
pitch: BASE_PITCH,
|
||||
bearing: BASE_BEARING,
|
||||
duration: 1500,
|
||||
easing: (t) => t * (2 - t), // easeOutQuad
|
||||
easing: (t) => t * (2 - t),
|
||||
})
|
||||
|
||||
// Show all clusters again when closing popup
|
||||
if (map.getLayer("clusters")) {
|
||||
map.getMap().setLayoutProperty("clusters", "visibility", "visible")
|
||||
}
|
||||
|
@ -122,7 +152,6 @@ export default function Layers({
|
|||
map.getMap().setLayoutProperty("unclustered-point", "visibility", "visible")
|
||||
}
|
||||
|
||||
// Update fill color for all districts
|
||||
if (map.getLayer("district-fill")) {
|
||||
const fillColorExpression = createFillColorExpression(null, crimeDataByDistrict)
|
||||
map.getMap().setPaintProperty("district-fill", "fill-color", fillColorExpression as any)
|
||||
|
@ -130,32 +159,26 @@ export default function Layers({
|
|||
}
|
||||
}, [map, crimeDataByDistrict])
|
||||
|
||||
// Handle district popup close specifically
|
||||
const handleCloseDistrictPopup = useCallback(() => {
|
||||
console.log("Closing district popup")
|
||||
handlePopupClose()
|
||||
}, [handlePopupClose])
|
||||
|
||||
// Handle incident popup close specifically
|
||||
const handleCloseIncidentPopup = useCallback(() => {
|
||||
console.log("Closing incident popup")
|
||||
handlePopupClose()
|
||||
}, [handlePopupClose])
|
||||
|
||||
// Handle district clicks
|
||||
const handleDistrictClick = useCallback(
|
||||
(feature: IDistrictFeature) => {
|
||||
console.log("District clicked:", feature)
|
||||
|
||||
// Clear any incident selection when showing a district
|
||||
setSelectedIncident(null)
|
||||
|
||||
// Set the selected district
|
||||
setSelectedDistrict(feature)
|
||||
selectedDistrictRef.current = feature
|
||||
setFocusedDistrictId(feature.id)
|
||||
|
||||
// Fly to the district
|
||||
if (map && feature.longitude && feature.latitude) {
|
||||
map.flyTo({
|
||||
center: [feature.longitude, feature.latitude],
|
||||
|
@ -166,7 +189,6 @@ export default function Layers({
|
|||
easing: (t) => t * (2 - t),
|
||||
})
|
||||
|
||||
// Hide clusters when focusing on district
|
||||
if (map.getLayer("clusters")) {
|
||||
map.getMap().setLayoutProperty("clusters", "visibility", "none")
|
||||
}
|
||||
|
@ -178,7 +200,6 @@ export default function Layers({
|
|||
[map],
|
||||
)
|
||||
|
||||
// Set up custom event handler for fly-to events
|
||||
useEffect(() => {
|
||||
if (!mapboxMap) return
|
||||
|
||||
|
@ -206,7 +227,6 @@ export default function Layers({
|
|||
}
|
||||
}, [mapboxMap, map])
|
||||
|
||||
// Handle incident click events
|
||||
useEffect(() => {
|
||||
if (!mapboxMap) return
|
||||
|
||||
|
@ -214,13 +234,11 @@ export default function Layers({
|
|||
const customEvent = e as CustomEvent
|
||||
console.log("Received incident_click event in layers:", customEvent.detail)
|
||||
|
||||
// Enhanced error checking
|
||||
if (!customEvent.detail) {
|
||||
console.error("Empty incident click event data")
|
||||
return
|
||||
}
|
||||
|
||||
// Allow for different property names in the event data
|
||||
const incidentId = customEvent.detail.id || customEvent.detail.incidentId || customEvent.detail.incident_id
|
||||
|
||||
if (!incidentId) {
|
||||
|
@ -230,10 +248,8 @@ export default function Layers({
|
|||
|
||||
console.log("Looking for incident with ID:", incidentId)
|
||||
|
||||
// Improved incident finding
|
||||
let foundIncident: ICrimeIncident | undefined
|
||||
|
||||
// First try to use the data directly from the event if it has all needed properties
|
||||
if (
|
||||
customEvent.detail.latitude !== undefined &&
|
||||
customEvent.detail.longitude !== undefined &&
|
||||
|
@ -252,7 +268,6 @@ export default function Layers({
|
|||
address: customEvent.detail.address,
|
||||
}
|
||||
} else {
|
||||
// Otherwise search through the crimes data
|
||||
for (const crime of crimes) {
|
||||
for (const incident of crime.crime_incidents) {
|
||||
if (incident.id === incidentId || incident.id?.toString() === incidentId?.toString()) {
|
||||
|
@ -288,20 +303,16 @@ export default function Layers({
|
|||
|
||||
console.log("Setting selected incident:", foundIncident)
|
||||
|
||||
// Clear any existing district selection first
|
||||
setSelectedDistrict(null)
|
||||
selectedDistrictRef.current = null
|
||||
setFocusedDistrictId(null)
|
||||
|
||||
// Set the selected incident
|
||||
setSelectedIncident(foundIncident)
|
||||
}
|
||||
|
||||
// Add event listeners to both the map canvas and document
|
||||
mapboxMap.getCanvas().addEventListener("incident_click", handleIncidentClickEvent as EventListener)
|
||||
document.addEventListener("incident_click", handleIncidentClickEvent as EventListener)
|
||||
|
||||
// For debugging purposes, log when this effect runs
|
||||
console.log("Set up incident click event listener")
|
||||
|
||||
return () => {
|
||||
|
@ -313,7 +324,6 @@ export default function Layers({
|
|||
}
|
||||
}, [mapboxMap, crimes, setFocusedDistrictId])
|
||||
|
||||
// Update selected district when year/month/filter changes
|
||||
useEffect(() => {
|
||||
if (selectedDistrictRef.current) {
|
||||
const districtId = selectedDistrictRef.current.id
|
||||
|
@ -395,7 +405,6 @@ export default function Layers({
|
|||
}
|
||||
}, [crimes, filterCategory, year, month, crimeDataByDistrict])
|
||||
|
||||
// Make sure we have a defined handler for setFocusedDistrictId
|
||||
const handleSetFocusedDistrictId = useCallback((id: string | null, isMarkerClick = false) => {
|
||||
console.log("Setting focused district ID:", id, "from marker click:", isMarkerClick)
|
||||
setFocusedDistrictId(id)
|
||||
|
@ -403,23 +412,18 @@ export default function Layers({
|
|||
|
||||
if (!visible) return null
|
||||
|
||||
// Determine which layers should be visible based on the active control
|
||||
const showDistrictLayer = activeControl === "incidents"
|
||||
const showHeatmapLayer = activeControl === "heatmap"
|
||||
const showClustersLayer = activeControl === "clusters"
|
||||
const showUnitsLayer = activeControl === "units"
|
||||
const showTimelineLayer = activeControl === "timeline"
|
||||
|
||||
// District fill should only be visible for incidents and clusters
|
||||
const showDistrictFill = activeControl === "incidents" || activeControl === "clusters"
|
||||
|
||||
// Show incident markers for incidents, clusters, AND units modes
|
||||
// But hide for heatmap and timeline
|
||||
const showIncidentMarkers = activeControl !== "heatmap" && activeControl !== "timeline"
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Standard District Layer with incident points */}
|
||||
<DistrictFillLineLayer
|
||||
visible={true}
|
||||
map={mapboxMap}
|
||||
|
@ -433,10 +437,9 @@ export default function Layers({
|
|||
crimeDataByDistrict={crimeDataByDistrict}
|
||||
showFill={showDistrictFill}
|
||||
activeControl={activeControl}
|
||||
onDistrictClick={handleDistrictClick} // Add this prop to pass the click handler
|
||||
onDistrictClick={handleDistrictClick}
|
||||
/>
|
||||
|
||||
{/* Heatmap Layer */}
|
||||
<HeatmapLayer
|
||||
crimes={crimes}
|
||||
year={year}
|
||||
|
@ -448,7 +451,6 @@ export default function Layers({
|
|||
setFocusedDistrictId={handleSetFocusedDistrictId}
|
||||
/>
|
||||
|
||||
{/* Timeline Layer - make sure this is the only visible layer in timeline mode */}
|
||||
<TimelineLayer
|
||||
crimes={crimes}
|
||||
year={year}
|
||||
|
@ -459,7 +461,6 @@ export default function Layers({
|
|||
useAllData={useAllData}
|
||||
/>
|
||||
|
||||
{/* Units Layer - always show incidents when Units is active */}
|
||||
<UnitsLayer
|
||||
crimes={crimes}
|
||||
units={units}
|
||||
|
@ -468,7 +469,6 @@ export default function Layers({
|
|||
map={mapboxMap}
|
||||
/>
|
||||
|
||||
{/* Cluster Layer - only enable when the clusters control is active and NOT in timeline mode */}
|
||||
<ClusterLayer
|
||||
visible={visible && activeControl === "clusters"}
|
||||
map={mapboxMap}
|
||||
|
@ -479,7 +479,6 @@ export default function Layers({
|
|||
showClusters={activeControl === "clusters"}
|
||||
/>
|
||||
|
||||
{/* Unclustered Points Layer - now show for both incidents and units modes */}
|
||||
<UnclusteredPointLayer
|
||||
visible={visible && showIncidentMarkers && !focusedDistrictId}
|
||||
map={mapboxMap}
|
||||
|
@ -488,7 +487,6 @@ export default function Layers({
|
|||
focusedDistrictId={focusedDistrictId}
|
||||
/>
|
||||
|
||||
{/* District Popup */}
|
||||
{selectedDistrict && !selectedIncident && (
|
||||
<>
|
||||
<DistrictPopup
|
||||
|
@ -511,15 +509,30 @@ export default function Layers({
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Timeline Layer - only show if active control is timeline */}
|
||||
<TimezoneLayer map={mapboxMap} />
|
||||
|
||||
{/* Fault line layer */}
|
||||
<FaultLinesLayer map={mapboxMap} />
|
||||
|
||||
<CoastlineLayer
|
||||
<CoastlineLayer map={mapboxMap} />
|
||||
|
||||
{showEWS && (
|
||||
<EWSAlertLayer
|
||||
map={mapboxMap}
|
||||
incidents={ewsIncidents}
|
||||
onIncidentResolved={handleResolveIncident}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEWS && showPanicDemo && (
|
||||
<div className="absolute bottom-20 left-0 z-50 p-2">
|
||||
<PanicButtonDemo
|
||||
onTriggerAlert={handleTriggerAlert}
|
||||
onResolveAllAlerts={handleResolveAllAlerts}
|
||||
activeIncidents={ewsIncidents.filter(inc => inc.status === 'active')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incident Popup */}
|
||||
{selectedIncident && selectedIncident.latitude && selectedIncident.longitude && (
|
||||
<CrimePopup
|
||||
longitude={selectedIncident.longitude}
|
||||
|
@ -528,24 +541,6 @@ export default function Layers({
|
|||
incident={selectedIncident}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Debug info for development */}
|
||||
{/* <div
|
||||
className="text-red-500 bg-accent"
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 10,
|
||||
left: 10,
|
||||
backgroundColor: "rgba(255,255,255,0.7)",
|
||||
padding: "5px",
|
||||
zIndex: 999,
|
||||
display: process.env.NODE_ENV === "development" ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
<div>Selected District: {selectedDistrict ? selectedDistrict.name : "None"}</div>
|
||||
<div>Selected Incident: {selectedIncident ? selectedIncident.id : "None"}</div>
|
||||
<div>Focused District ID: {focusedDistrictId || "None"}</div>
|
||||
</div> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ function Jam({ timeZone }: { timeZone: string }) {
|
|||
const options: Intl.DateTimeFormatOptions = {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit', // Added seconds display
|
||||
hour12: false,
|
||||
timeZone
|
||||
};
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/* EWS Alert Marker */
|
||||
.ews-alert-marker {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pulsing-dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgb(220, 38, 38);
|
||||
box-shadow: 0 0 0 rgba(220, 38, 38, 0.4);
|
||||
transform-origin: center center;
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.ews-alert-content {
|
||||
position: absolute;
|
||||
min-width: 200px;
|
||||
z-index: 1;
|
||||
top: -8px;
|
||||
left: 15px;
|
||||
}
|
||||
|
||||
/* Animation for alert transitions */
|
||||
@keyframes alert-pulse {
|
||||
0% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 82, 82, 0.7);
|
||||
}
|
||||
|
||||
70% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 10px rgba(255, 82, 82, 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(0.95);
|
||||
box-shadow: 0 0 0 0 rgba(255, 82, 82, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* EWS Status Indicator */
|
||||
#ews-status-indicator {
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
|
@ -2,6 +2,9 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@import "./ews.css";
|
||||
@import "./ui.css";
|
||||
|
||||
@import url(//fonts.googleapis.com/css?family=Roboto+Condensed:400,600,700);
|
||||
|
||||
@layer base {
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
import { IIncidentLog } from "../types/ews";
|
||||
|
||||
// Jember area coordinates
|
||||
const JEMBER_LOCATIONS = [
|
||||
{ latitude: -8.172380, longitude: 113.702588, district: "Kaliwates", address: "Jl. Gajah Mada No. 233, Kaliwates" },
|
||||
{ latitude: -8.184859, longitude: 113.668811, district: "Sumbersari", address: "Jl. Kalimantan No.37, Sumbersari" },
|
||||
{ latitude: -8.166498, longitude: 113.722759, district: "Patrang", address: "Jl. Mastrip No. 49, Patrang" },
|
||||
{ latitude: -8.159021, longitude: 113.713175, district: "Jemberlor", address: "Jl. Letjen Panjaitan No. 55, Jemberlor" },
|
||||
{ latitude: -8.192226, longitude: 113.669716, district: "Kebonsari", address: "Perumahan Kebonsari Indah, Blok C-15" },
|
||||
];
|
||||
|
||||
// Generate mock incident log
|
||||
export const generateMockIncident = (override: Partial<IIncidentLog> = {}): IIncidentLog => {
|
||||
const locationIndex = Math.floor(Math.random() * JEMBER_LOCATIONS.length);
|
||||
const location = JEMBER_LOCATIONS[locationIndex];
|
||||
const priorityOptions = ['high', 'medium', 'low'] as const;
|
||||
const priority = override.priority || priorityOptions[Math.floor(Math.random() * priorityOptions.length)];
|
||||
|
||||
const reporters = [
|
||||
{ id: "USR001", name: "Budi Santoso", phone: "081234567890" },
|
||||
{ id: "USR002", name: "Dewi Putri", phone: "085678901234" },
|
||||
{ id: "USR003", name: "Ahmad Rizki", phone: "087890123456" },
|
||||
{ id: "USR004", name: "Siti Nurhaliza", phone: "089012345678" }
|
||||
];
|
||||
|
||||
const reporterIndex = Math.floor(Math.random() * reporters.length);
|
||||
|
||||
return {
|
||||
id: override.id || `INC${Math.floor(Math.random() * 10000).toString().padStart(4, '0')}`,
|
||||
timestamp: override.timestamp || new Date(),
|
||||
location: override.location || {
|
||||
latitude: location.latitude + (Math.random() * 0.01 - 0.005),
|
||||
longitude: location.longitude + (Math.random() * 0.01 - 0.005),
|
||||
address: location.address,
|
||||
district: location.district
|
||||
},
|
||||
status: override.status || 'active',
|
||||
reporter: override.reporter || reporters[reporterIndex],
|
||||
description: override.description || "Panic button activated",
|
||||
category: override.category || "Emergency Alert",
|
||||
priority,
|
||||
response_time: override.response_time,
|
||||
};
|
||||
};
|
||||
|
||||
// List of mock incidents (initially empty)
|
||||
export const mockIncidents: IIncidentLog[] = [];
|
||||
|
||||
// Add a new incident to the mock data
|
||||
export const addMockIncident = (incident: Partial<IIncidentLog> = {}): IIncidentLog => {
|
||||
const newIncident = generateMockIncident(incident);
|
||||
mockIncidents.push(newIncident);
|
||||
return newIncident;
|
||||
};
|
||||
|
||||
// Get all incidents
|
||||
export const getAllIncidents = (): IIncidentLog[] => {
|
||||
return [...mockIncidents];
|
||||
};
|
||||
|
||||
// Get active incidents
|
||||
export const getActiveIncidents = (): IIncidentLog[] => {
|
||||
return mockIncidents.filter(incident => incident.status === 'active');
|
||||
};
|
||||
|
||||
// Resolve an incident
|
||||
export const resolveIncident = (id: string): IIncidentLog | undefined => {
|
||||
const incident = mockIncidents.find(inc => inc.id === id);
|
||||
if (incident) {
|
||||
incident.status = 'resolved';
|
||||
incident.response_time = Math.floor(Math.random() * 300) + 60; // 1-5 minutes response time
|
||||
}
|
||||
return incident;
|
||||
};
|
|
@ -7,6 +7,7 @@ import {
|
|||
districts,
|
||||
geographics,
|
||||
locations,
|
||||
incident_logs,
|
||||
} from '@prisma/client';
|
||||
|
||||
export interface ICrimes extends crimes {
|
||||
|
@ -91,3 +92,16 @@ export interface IDistanceResult {
|
|||
district_name: string;
|
||||
distance_meters: number;
|
||||
}
|
||||
|
||||
export interface IIncidentLogs {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
source: string;
|
||||
time: Date;
|
||||
description: string;
|
||||
user_id: string;
|
||||
location_id: string;
|
||||
category_id: string;
|
||||
verified: boolean;
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
export interface IEWSLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
address?: string;
|
||||
district?: string;
|
||||
}
|
||||
|
||||
export interface IIncidentLog {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
location: IEWSLocation;
|
||||
status: 'active' | 'resolved' | 'false_alarm';
|
||||
reporter: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone?: string;
|
||||
};
|
||||
description?: string;
|
||||
category?: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
response_time?: number; // in seconds
|
||||
}
|
||||
|
||||
export type EWSStatus = 'idle' | 'alert' | 'responding';
|
Loading…
Reference in New Issue