MIF_E31221222/sigap-website/app/_components/map/layers/ews-alert-layer.tsx

319 lines
13 KiB
TypeScript

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