feat: Implement custom animated popup for EWS alerts with wave circles and enhanced styling
This commit is contained in:
parent
8a1a4320c2
commit
f4b1d9d633
|
@ -5,9 +5,12 @@ import mapboxgl from 'mapbox-gl';
|
||||||
import { IIncidentLog, EWSStatus } from '@/app/_utils/types/ews';
|
import { IIncidentLog, EWSStatus } from '@/app/_utils/types/ews';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { AlertTriangle, X } from 'lucide-react';
|
import { AlertTriangle, X } from 'lucide-react';
|
||||||
|
|
||||||
import DigitalClock from '../markers/digital-clock';
|
import DigitalClock from '../markers/digital-clock';
|
||||||
import { Badge } from '@/app/_components/ui/badge';
|
import { Badge } from '@/app/_components/ui/badge';
|
||||||
import { Button } from '@/app/_components/ui/button';
|
import { Button } from '@/app/_components/ui/button';
|
||||||
|
import { IconCancel } from '@tabler/icons-react';
|
||||||
|
import { CustomAnimatedPopup } from '@/app/_utils/map/custom-animated-popup';
|
||||||
|
|
||||||
interface EWSAlertLayerProps {
|
interface EWSAlertLayerProps {
|
||||||
map: mapboxgl.Map | null;
|
map: mapboxgl.Map | null;
|
||||||
|
@ -27,70 +30,60 @@ export default function EWSAlertLayer({
|
||||||
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map());
|
const markersRef = useRef<Map<string, mapboxgl.Marker>>(new Map());
|
||||||
const animationFrameRef = useRef<number | null>(null);
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
const alertAudioRef = useRef<HTMLAudioElement | null>(null);
|
const alertAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const notificationAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const warningAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
const pulsingDotsRef = useRef<Record<string, HTMLDivElement>>({}); // For animation reference
|
const pulsingDotsRef = useRef<Record<string, HTMLDivElement>>({}); // For animation reference
|
||||||
|
const sireneAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [showAlert, setShowAlert] = useState(false);
|
||||||
|
const [currentAlert, setCurrentAlert] = useState<IIncidentLog | null>(null);
|
||||||
|
|
||||||
// Initialize audio
|
// Initialize audio
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
// Try multiple possible audio sources in order of preference
|
// Initialize different audio elements for different purposes
|
||||||
const possibleSources = [
|
// alertAudioRef.current = new Audio("/sounds/error-2-126514.mp3");
|
||||||
'/sounds/alert.mp3',
|
// notificationAudioRef.current = new Audio("/sounds/system-notification-199277.mp3");
|
||||||
'/alert.mp3',
|
// warningAudioRef.current = new Audio("/sounds/error-call-to-attention-129258.mp3");
|
||||||
'/sounds/error.mp3',
|
sireneAudioRef.current = new Audio("/sounds/security-alarm-80493.mp3");
|
||||||
'/error.mp3',
|
|
||||||
'/sounds/notification.mp3'
|
|
||||||
];
|
|
||||||
|
|
||||||
// Try to load the first audio source
|
// Configure audio elements
|
||||||
alertAudioRef.current = new Audio("/sounds/error-2-126514.mp3");
|
[alertAudioRef, notificationAudioRef, warningAudioRef].forEach(audioRef => {
|
||||||
|
if (audioRef.current) {
|
||||||
// Add error handling to try alternative sources
|
audioRef.current.volume = 0.5;
|
||||||
alertAudioRef.current.addEventListener('error', (e) => {
|
audioRef.current.load();
|
||||||
// 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 for main alert
|
||||||
|
|
||||||
// Loop handling - stop after 1 minute
|
|
||||||
let loopStartTime: number | null = null;
|
let loopStartTime: number | null = null;
|
||||||
|
if (alertAudioRef.current) {
|
||||||
|
alertAudioRef.current.addEventListener('ended', () => {
|
||||||
|
// Initialize start time on first play
|
||||||
|
if (loopStartTime === null) {
|
||||||
|
loopStartTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
alertAudioRef.current.addEventListener('ended', () => {
|
// Check if 1 minute has passed
|
||||||
// Initialize start time on first play
|
if (Date.now() - loopStartTime < 60000) { // 60000ms = 1 minute
|
||||||
if (loopStartTime === null) {
|
alertAudioRef.current?.play().catch(err =>
|
||||||
loopStartTime = Date.now();
|
console.error("Error playing looped alert sound:", err));
|
||||||
}
|
} else {
|
||||||
|
loopStartTime = null; // Reset for future alerts
|
||||||
// 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) {
|
} catch (err) {
|
||||||
console.error("Could not initialize alert audio:", err);
|
console.error("Could not initialize alert audio:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (alertAudioRef.current) {
|
// Cleanup all audio elements
|
||||||
alertAudioRef.current.pause();
|
[alertAudioRef, notificationAudioRef, warningAudioRef].forEach(audioRef => {
|
||||||
alertAudioRef.current = null;
|
if (audioRef.current) {
|
||||||
}
|
audioRef.current.pause();
|
||||||
|
audioRef.current = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
@ -99,30 +92,37 @@ export default function EWSAlertLayer({
|
||||||
const newActiveIncidents = incidents.filter(inc => inc.status === 'active');
|
const newActiveIncidents = incidents.filter(inc => inc.status === 'active');
|
||||||
setActiveIncidents(newActiveIncidents);
|
setActiveIncidents(newActiveIncidents);
|
||||||
|
|
||||||
// Update EWS status
|
// Update EWS status and trigger alert display
|
||||||
if (newActiveIncidents.length > 0) {
|
if (newActiveIncidents.length > 0) {
|
||||||
setEwsStatus('alert');
|
setEwsStatus('alert');
|
||||||
// Play alert sound with error handling
|
|
||||||
if (alertAudioRef.current) {
|
// Play notification sound first
|
||||||
alertAudioRef.current.play()
|
if (notificationAudioRef.current) {
|
||||||
.catch(err => {
|
notificationAudioRef.current.play()
|
||||||
console.error("Error playing alert sound:", err);
|
.catch(err => console.error("Error playing notification 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set the most recent incident as current alert
|
||||||
|
const newestIncident = newActiveIncidents[newActiveIncidents.length - 1];
|
||||||
|
setCurrentAlert(newestIncident);
|
||||||
|
setShowAlert(true);
|
||||||
|
|
||||||
|
// Play warning sound after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (warningAudioRef.current) {
|
||||||
|
warningAudioRef.current.play()
|
||||||
|
.catch(err => console.error("Error playing warning sound:", err));
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// Auto-close the alert after 15 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowAlert(false);
|
||||||
|
}, 5000);
|
||||||
} else {
|
} else {
|
||||||
setEwsStatus('idle');
|
setEwsStatus('idle');
|
||||||
|
setShowAlert(false);
|
||||||
|
setCurrentAlert(null);
|
||||||
}
|
}
|
||||||
}, [incidents]);
|
}, [incidents]);
|
||||||
|
|
||||||
|
@ -148,24 +148,54 @@ export default function EWSAlertLayer({
|
||||||
|
|
||||||
const { latitude, longitude } = incident.location;
|
const { latitude, longitude } = incident.location;
|
||||||
|
|
||||||
// Create marker element
|
// Create marker element with animated circles (similar to TitikGempa)
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'ews-alert-marker';
|
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
|
// Create the content for the marker
|
||||||
const contentElement = document.createElement('div');
|
const contentElement = document.createElement('div');
|
||||||
contentElement.className = 'ews-alert-content';
|
contentElement.className = 'marker-gempa';
|
||||||
|
|
||||||
// Use React for the popup content
|
// Use React for the marker content with animated circles
|
||||||
const root = createRoot(contentElement);
|
const markerRoot = createRoot(contentElement);
|
||||||
root.render(
|
markerRoot.render(
|
||||||
|
<div className="circles flex justify-center items-center">
|
||||||
|
<div className="circle1"></div>
|
||||||
|
<div className="circle2"></div>
|
||||||
|
<div className="circle3"></div>
|
||||||
|
<IconCancel className="blink" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the content element to the marker
|
||||||
|
el.appendChild(contentElement);
|
||||||
|
|
||||||
|
// Create and add the marker
|
||||||
|
const marker = new mapboxgl.Marker({
|
||||||
|
element: el,
|
||||||
|
anchor: 'center'
|
||||||
|
})
|
||||||
|
.setLngLat([longitude, latitude])
|
||||||
|
.addTo(map);
|
||||||
|
|
||||||
|
// Create the popup content
|
||||||
|
const popupElement = document.createElement('div');
|
||||||
|
const popupRoot = createRoot(popupElement);
|
||||||
|
|
||||||
|
popupRoot.render(
|
||||||
<div className="relative">
|
<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="w-full overflow-hidden">
|
||||||
|
<div className="strip-wrapper">
|
||||||
|
<div className="strip-bar loop-strip-reverse anim-duration-20"></div>
|
||||||
|
<div className="strip-bar loop-strip-reverse anim-duration-20"></div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-0 bottom-0 left-0 right-0 flex justify-center items-center">
|
||||||
|
<p className="p-1 bg-black font-bold text-xs text-glow uppercase">
|
||||||
|
{incident.priority} PRIORITY
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="relative z-40 bg-red-600 text-white p-2 rounded-lg shadow-lg min-w-[200px] max-w-[300px]">
|
<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">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<Badge variant="outline" className={`
|
<Badge variant="outline" className={`
|
||||||
|
@ -185,7 +215,7 @@ export default function EWSAlertLayer({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="font-bold flex items-center gap-1">
|
<h3 className="font-bold flex items-center gap-1 text-glow">
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<AlertTriangle className="h-4 w-4" />
|
||||||
{incident.category || "Emergency Alert"}
|
{incident.category || "Emergency Alert"}
|
||||||
</h3>
|
</h3>
|
||||||
|
@ -213,20 +243,34 @@ export default function EWSAlertLayer({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add the elements to the marker
|
// Create and attach the animated popup
|
||||||
el.appendChild(pulsingDot);
|
const popup = new CustomAnimatedPopup({
|
||||||
el.appendChild(contentElement);
|
closeOnClick: false,
|
||||||
|
openingAnimation: {
|
||||||
// Create and add the marker
|
duration: 300,
|
||||||
const marker = new mapboxgl.Marker({
|
easing: 'ease-out',
|
||||||
element: el,
|
transform: 'scale'
|
||||||
anchor: 'center'
|
},
|
||||||
})
|
closingAnimation: {
|
||||||
.setLngLat([longitude, latitude])
|
duration: 200,
|
||||||
.addTo(map);
|
easing: 'ease-in-out',
|
||||||
|
transform: 'scale'
|
||||||
|
}
|
||||||
|
}).setDOMContent(popupElement);
|
||||||
|
|
||||||
|
marker.setPopup(popup);
|
||||||
markersRef.current.set(incident.id, marker);
|
markersRef.current.set(incident.id, marker);
|
||||||
|
|
||||||
|
// Add wave circles around the incident point
|
||||||
|
if (map) {
|
||||||
|
popup.addWaveCircles(map, new mapboxgl.LngLat(longitude, latitude), {
|
||||||
|
color: incident.priority === 'high' ? '#ff0000' :
|
||||||
|
incident.priority === 'medium' ? '#ff9900' : '#0066ff',
|
||||||
|
maxRadius: 300,
|
||||||
|
count: 4
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Fly to the incident if it's new
|
// Fly to the incident if it's new
|
||||||
const isNewIncident = activeIncidents.length > 0 &&
|
const isNewIncident = activeIncidents.length > 0 &&
|
||||||
incident.id === activeIncidents[activeIncidents.length - 1].id;
|
incident.id === activeIncidents[activeIncidents.length - 1].id;
|
||||||
|
@ -245,25 +289,14 @@ export default function EWSAlertLayer({
|
||||||
});
|
});
|
||||||
|
|
||||||
map.getContainer().dispatchEvent(flyToEvent);
|
map.getContainer().dispatchEvent(flyToEvent);
|
||||||
|
|
||||||
|
// Auto-open popup for the newest incident
|
||||||
|
setTimeout(() => {
|
||||||
|
popup.addTo(map);
|
||||||
|
}, 2000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 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
|
// Cleanup function
|
||||||
return () => {
|
return () => {
|
||||||
if (animationFrameRef.current !== null) {
|
if (animationFrameRef.current !== null) {
|
||||||
|
@ -314,5 +347,63 @@ export default function EWSAlertLayer({
|
||||||
};
|
};
|
||||||
}, [map, ewsStatus, activeIncidents.length]);
|
}, [map, ewsStatus, activeIncidents.length]);
|
||||||
|
|
||||||
return null; // This component doesn't render anything directly
|
// Render the full-screen alert overlay when a new incident is detected
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showAlert && currentAlert && (
|
||||||
|
<div className='absolute m-auto top-0 bottom-0 left-0 right-0 flex flex-col justify-center items-center'>
|
||||||
|
<div className='fixed m-auto top-0 bottom-0 left-0 right-0 flex flex-col justify-center items-center overlay-bg'></div>
|
||||||
|
<div className='warning scale-75 md:scale-150 flex flex-col justify-center items-center'>
|
||||||
|
<div className='long-hex flex flex-col justify-center opacity-0 show-pop-up animation-delay-1'>
|
||||||
|
<div className="flex justify-evenly w-full items-center">
|
||||||
|
<div className='warning-black opacity-0 blink animation-fast animation-delay-2'></div>
|
||||||
|
<div className='flex flex-col font-bold text-center text-black'>
|
||||||
|
<span className='text-xl'>PERINGATAN</span>
|
||||||
|
<span className='text-xs'>{currentAlert.category || "Emergency Alert"}</span>
|
||||||
|
</div>
|
||||||
|
<div className='warning-black opacity-0 blink animation-fast animation-delay-2'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex justify-between">
|
||||||
|
<div className="warning-black-hex -mt-20 show-pop-up"></div>
|
||||||
|
<div className="warning-black-hex -mt-20 show-pop-up"></div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex justify-center info">
|
||||||
|
<div className="basic-hex -mt-12 -mr-2 opacity-0 show-pop-up flex flex-col justify-center items-center text-glow">
|
||||||
|
<p className='text-xl'>{currentAlert.priority}</p>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
}}>PRIORITY</p>
|
||||||
|
</div>
|
||||||
|
<div className="basic-hex opacity-0 show-pop-up"></div>
|
||||||
|
<div className="basic-hex -mt-12 -ml-2 opacity-0 show-pop-up flex flex-col justify-center items-center text-glow">
|
||||||
|
<p className='text-xl'>{currentAlert.location.district}</p>
|
||||||
|
<p style={{
|
||||||
|
fontSize: '10px',
|
||||||
|
}}>LOCATION</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full flex justify-between show-pop-up">
|
||||||
|
<div className="warning-yellow -mt-24 ml-6 opacity-0 blink animation-delay-2"></div>
|
||||||
|
<div className="warning-yellow -mt-24 mr-6 opacity-0 blink animation-delay-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='strip top-0'>
|
||||||
|
<div className='strip-wrapper'><div className='strip-bar loop-strip-reverse'></div><div className='strip-bar loop-strip-reverse'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='strip bottom-0'>
|
||||||
|
<div className='strip-wrapper'><div className='strip-bar loop-strip'></div><div className='strip-bar loop-strip'></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className="absolute top-4 right-4 bg-transparent border border-white hover:bg-red-800"
|
||||||
|
onClick={() => setShowAlert(false)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -48,3 +48,98 @@
|
||||||
#ews-status-indicator {
|
#ews-status-indicator {
|
||||||
transition: all 0.3s ease-in-out;
|
transition: all 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Add to your global CSS file */
|
||||||
|
.custom-animated-popup {
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-gempa {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circles {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle1, .circle2, .circle3 {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid red;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle2 {
|
||||||
|
animation-delay: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle3 {
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blink {
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
color: red;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For the warning styles */
|
||||||
|
.overlay-bg {
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-glow {
|
||||||
|
text-shadow: 0 0 5px rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning {
|
||||||
|
z-index: 1001;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animation classes */
|
||||||
|
.show-pop-up {
|
||||||
|
animation: showPopUp 0.5s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-pop-up {
|
||||||
|
animation: closePopUp 0.5s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes showPopUp {
|
||||||
|
from { opacity: 0; transform: scale(0.8); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes closePopUp {
|
||||||
|
from { opacity: 1; transform: scale(1); }
|
||||||
|
to { opacity: 0; transform: scale(0.8); }
|
||||||
|
}
|
|
@ -0,0 +1,321 @@
|
||||||
|
import mapboxgl from 'mapbox-gl';
|
||||||
|
|
||||||
|
interface AnimationOptions {
|
||||||
|
duration: number;
|
||||||
|
easing: string;
|
||||||
|
transform: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomPopupOptions extends mapboxgl.PopupOptions {
|
||||||
|
openingAnimation?: AnimationOptions;
|
||||||
|
closingAnimation?: AnimationOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extend the native Mapbox Popup
|
||||||
|
export class CustomAnimatedPopup extends mapboxgl.Popup {
|
||||||
|
private openingAnimation: AnimationOptions;
|
||||||
|
private closingAnimation: AnimationOptions;
|
||||||
|
private animating = false;
|
||||||
|
|
||||||
|
constructor(options: CustomPopupOptions = {}) {
|
||||||
|
// Extract animation options and pass the rest to the parent class
|
||||||
|
const {
|
||||||
|
openingAnimation,
|
||||||
|
closingAnimation,
|
||||||
|
className,
|
||||||
|
...mapboxOptions
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Add our custom class to the className
|
||||||
|
const customClassName = `custom-animated-popup ${className || ''}`.trim();
|
||||||
|
|
||||||
|
// Call the parent constructor
|
||||||
|
super({
|
||||||
|
...mapboxOptions,
|
||||||
|
className: customClassName,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store animation options
|
||||||
|
this.openingAnimation = openingAnimation || {
|
||||||
|
duration: 300,
|
||||||
|
easing: 'ease-out',
|
||||||
|
transform: 'scale'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.closingAnimation = closingAnimation || {
|
||||||
|
duration: 200,
|
||||||
|
easing: 'ease-in-out',
|
||||||
|
transform: 'scale'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override the parent's add method
|
||||||
|
const parentAdd = this.addTo;
|
||||||
|
this.addTo = (map: mapboxgl.Map) => {
|
||||||
|
// Call the parent method first
|
||||||
|
parentAdd.call(this, map);
|
||||||
|
|
||||||
|
// Apply animation after a short delay to ensure the element is in the DOM
|
||||||
|
setTimeout(() => this.animateOpen(), 10);
|
||||||
|
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the remove method to add animation
|
||||||
|
remove(): this {
|
||||||
|
if (this.animating) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animateClose(() => {
|
||||||
|
super.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation methods
|
||||||
|
private animateOpen(): void {
|
||||||
|
const container = this._container;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Apply initial state
|
||||||
|
container.style.opacity = '0';
|
||||||
|
container.style.transform = 'scale(0.8)';
|
||||||
|
container.style.transition = `
|
||||||
|
opacity ${this.openingAnimation.duration}ms ${this.openingAnimation.easing},
|
||||||
|
transform ${this.openingAnimation.duration}ms ${this.openingAnimation.easing}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Force reflow
|
||||||
|
void container.offsetHeight;
|
||||||
|
|
||||||
|
// Apply final state to trigger animation
|
||||||
|
container.style.opacity = '1';
|
||||||
|
container.style.transform = 'scale(1)';
|
||||||
|
}
|
||||||
|
|
||||||
|
private animateClose(callback: () => void): void {
|
||||||
|
const container = this._container;
|
||||||
|
if (!container) {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animating = true;
|
||||||
|
|
||||||
|
// Setup transition
|
||||||
|
container.style.transition = `
|
||||||
|
opacity ${this.closingAnimation.duration}ms ${this.closingAnimation.easing},
|
||||||
|
transform ${this.closingAnimation.duration}ms ${this.closingAnimation.easing}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Apply closing animation
|
||||||
|
container.style.opacity = '0';
|
||||||
|
container.style.transform = 'scale(0.8)';
|
||||||
|
|
||||||
|
// Execute callback after animation completes
|
||||||
|
setTimeout(() => {
|
||||||
|
this.animating = false;
|
||||||
|
callback();
|
||||||
|
}, this.closingAnimation.duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add method to create expanding wave circles
|
||||||
|
addWaveCircles(map: mapboxgl.Map, lngLat: mapboxgl.LngLat, options: {
|
||||||
|
color?: string,
|
||||||
|
maxRadius?: number,
|
||||||
|
duration?: number,
|
||||||
|
count?: number
|
||||||
|
} = {}): void {
|
||||||
|
const {
|
||||||
|
color = 'red',
|
||||||
|
maxRadius = 500,
|
||||||
|
duration = 4000,
|
||||||
|
count = 3
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Create source for wave circles if it doesn't exist
|
||||||
|
const sourceId = `wave-circles-${Math.random().toString(36).substring(2, 9)}`;
|
||||||
|
|
||||||
|
if (!map.getSource(sourceId)) {
|
||||||
|
map.addSource(sourceId, {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [lngLat.lng, lngLat.lat]
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
radius: 0
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add layers for each circle (with different animation delays)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const layerId = `${sourceId}-layer-${i}`;
|
||||||
|
const delay = i * (duration / count);
|
||||||
|
|
||||||
|
map.addLayer({
|
||||||
|
id: layerId,
|
||||||
|
type: 'circle',
|
||||||
|
source: sourceId,
|
||||||
|
paint: {
|
||||||
|
'circle-radius': ['interpolate', ['linear'], ['get', 'radius'], 0, 0, 100, maxRadius],
|
||||||
|
'circle-color': 'transparent',
|
||||||
|
'circle-opacity': ['interpolate', ['linear'], ['get', 'radius'], 0, 0.6, 100, 0],
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': color
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animate the circles
|
||||||
|
this.animateWaveCircle(map, sourceId, layerId, duration, delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private animateWaveCircle(
|
||||||
|
map: mapboxgl.Map,
|
||||||
|
sourceId: string,
|
||||||
|
layerId: string,
|
||||||
|
duration: number,
|
||||||
|
delay: number
|
||||||
|
): void {
|
||||||
|
let start: number | null = null;
|
||||||
|
let animationId: number;
|
||||||
|
|
||||||
|
const animate = (timestamp: number) => {
|
||||||
|
if (!start) {
|
||||||
|
start = timestamp + delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = Math.max(0, timestamp - start);
|
||||||
|
const progressPercent = Math.min(progress / duration, 1);
|
||||||
|
|
||||||
|
if (map.getSource(sourceId)) {
|
||||||
|
(map.getSource(sourceId) as mapboxgl.GeoJSONSource).setData({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [{
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: (map.getSource(sourceId) as any)._data.features[0].geometry.coordinates
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
radius: progressPercent * 100
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressPercent < 1) {
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
} else if (map.getLayer(layerId)) {
|
||||||
|
// Restart the animation for continuous effect
|
||||||
|
start = null;
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the animation after delay
|
||||||
|
setTimeout(() => {
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
}, delay);
|
||||||
|
|
||||||
|
// Clean up on popup close
|
||||||
|
this.once('close', () => {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
if (map.getLayer(layerId)) {
|
||||||
|
map.removeLayer(layerId);
|
||||||
|
}
|
||||||
|
if (map.getSource(sourceId) && !map.getLayer(layerId)) {
|
||||||
|
map.removeSource(sourceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add styles to document when in browser environment
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
// Add styles only if they don't exist yet
|
||||||
|
if (!document.getElementById('custom-animated-popup-styles')) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'custom-animated-popup-styles';
|
||||||
|
style.textContent = `
|
||||||
|
.custom-animated-popup {
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
.custom-animated-popup .mapboxgl-popup-content {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Marker styles with wave circles */
|
||||||
|
.marker-gempa {
|
||||||
|
position: relative;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circles {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle1, .circle2, .circle3 {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid red;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle2 {
|
||||||
|
animation-delay: 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle3 {
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blink {
|
||||||
|
animation: blink 1s infinite;
|
||||||
|
color: red;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
}
|
|
@ -47,6 +47,7 @@
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"mapbox-gl": "^3.11.0",
|
"mapbox-gl": "^3.11.0",
|
||||||
|
"mapbox-gl-animated-popup": "^0.4.0",
|
||||||
"ml-kmeans": "^6.0.0",
|
"ml-kmeans": "^6.0.0",
|
||||||
"motion": "^12.4.7",
|
"motion": "^12.4.7",
|
||||||
"next": "latest",
|
"next": "latest",
|
||||||
|
@ -11261,6 +11262,15 @@
|
||||||
"vt-pbf": "^3.1.3"
|
"vt-pbf": "^3.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mapbox-gl-animated-popup": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mapbox-gl-animated-popup/-/mapbox-gl-animated-popup-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-y7xP+xbcR5LfzpUHihWOa2ydIJCGUW7jNg0q1vi5PINc6q2hmB7Exnp+F4f1Kgb1WZj0WyZGjQC0WgTX7wuwgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"mapbox-gl": ">=0.48.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/marchingsquares": {
|
"node_modules/marchingsquares": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/marchingsquares/-/marchingsquares-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/marchingsquares/-/marchingsquares-1.3.3.tgz",
|
||||||
|
|
|
@ -53,6 +53,7 @@
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"mapbox-gl": "^3.11.0",
|
"mapbox-gl": "^3.11.0",
|
||||||
|
"mapbox-gl-animated-popup": "^0.4.0",
|
||||||
"ml-kmeans": "^6.0.0",
|
"ml-kmeans": "^6.0.0",
|
||||||
"motion": "^12.4.7",
|
"motion": "^12.4.7",
|
||||||
"next": "latest",
|
"next": "latest",
|
||||||
|
|
Loading…
Reference in New Issue