From f4b1d9d633106fd90db7a3a272535e99ccb8caea Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Wed, 7 May 2025 11:38:16 +0700 Subject: [PATCH] feat: Implement custom animated popup for EWS alerts with wave circles and enhanced styling --- .../map/layers/ews-alert-layer.tsx | 307 +++++++++++------ sigap-website/app/_styles/ews.css | 95 ++++++ .../app/_utils/map/custom-animated-popup.tsx | 321 ++++++++++++++++++ sigap-website/package-lock.json | 10 + sigap-website/package.json | 1 + 5 files changed, 626 insertions(+), 108 deletions(-) create mode 100644 sigap-website/app/_utils/map/custom-animated-popup.tsx diff --git a/sigap-website/app/_components/map/layers/ews-alert-layer.tsx b/sigap-website/app/_components/map/layers/ews-alert-layer.tsx index cf06de0..17704f2 100644 --- a/sigap-website/app/_components/map/layers/ews-alert-layer.tsx +++ b/sigap-website/app/_components/map/layers/ews-alert-layer.tsx @@ -5,9 +5,12 @@ 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'; +import { IconCancel } from '@tabler/icons-react'; +import { CustomAnimatedPopup } from '@/app/_utils/map/custom-animated-popup'; interface EWSAlertLayerProps { map: mapboxgl.Map | null; @@ -27,70 +30,60 @@ export default function EWSAlertLayer({ const markersRef = useRef>(new Map()); const animationFrameRef = useRef(null); const alertAudioRef = useRef(null); + const notificationAudioRef = useRef(null); + const warningAudioRef = useRef(null); const pulsingDotsRef = useRef>({}); // For animation reference + const sireneAudioRef = useRef(null); + const [showAlert, setShowAlert] = useState(false); + const [currentAlert, setCurrentAlert] = useState(null); // 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' - ]; + // Initialize different audio elements for different purposes + // alertAudioRef.current = new Audio("/sounds/error-2-126514.mp3"); + // notificationAudioRef.current = new Audio("/sounds/system-notification-199277.mp3"); + // warningAudioRef.current = new Audio("/sounds/error-call-to-attention-129258.mp3"); + sireneAudioRef.current = new Audio("/sounds/security-alarm-80493.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); - // } - // } + // Configure audio elements + [alertAudioRef, notificationAudioRef, warningAudioRef].forEach(audioRef => { + if (audioRef.current) { + audioRef.current.volume = 0.5; + audioRef.current.load(); + } }); - alertAudioRef.current.volume = 0.5; - - // Loop handling - stop after 1 minute + // Loop handling for main alert 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', () => { - // 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(); + // 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 + } + }); + } } catch (err) { console.error("Could not initialize alert audio:", err); } return () => { - if (alertAudioRef.current) { - alertAudioRef.current.pause(); - alertAudioRef.current = null; - } + // Cleanup all audio elements + [alertAudioRef, notificationAudioRef, warningAudioRef].forEach(audioRef => { + 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'); setActiveIncidents(newActiveIncidents); - // Update EWS status + // Update EWS status and trigger alert display 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); - } - }); + + // Play notification sound first + if (notificationAudioRef.current) { + notificationAudioRef.current.play() + .catch(err => console.error("Error playing notification sound:", err)); } + + // 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 { setEwsStatus('idle'); + setShowAlert(false); + setCurrentAlert(null); } }, [incidents]); @@ -148,24 +148,54 @@ export default function EWSAlertLayer({ const { latitude, longitude } = incident.location; - // Create marker element + // Create marker element with animated circles (similar to TitikGempa) 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'; + contentElement.className = 'marker-gempa'; - // Use React for the popup content - const root = createRoot(contentElement); - root.render( + // Use React for the marker content with animated circles + const markerRoot = createRoot(contentElement); + markerRoot.render( +
+
+
+
+ +
+ ); + + // 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(
-
+
+
+
+
+
+
+

+ {incident.priority} PRIORITY +

+
+
+
-

+

{incident.category || "Emergency Alert"}

@@ -213,20 +243,34 @@ export default function EWSAlertLayer({
); - // 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); + // Create and attach the animated popup + const popup = new CustomAnimatedPopup({ + closeOnClick: false, + openingAnimation: { + duration: 300, + easing: 'ease-out', + transform: 'scale' + }, + closingAnimation: { + duration: 200, + easing: 'ease-in-out', + transform: 'scale' + } + }).setDOMContent(popupElement); + marker.setPopup(popup); 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 const isNewIncident = activeIncidents.length > 0 && incident.id === activeIncidents[activeIncidents.length - 1].id; @@ -245,25 +289,14 @@ export default function EWSAlertLayer({ }); 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 return () => { if (animationFrameRef.current !== null) { @@ -314,5 +347,63 @@ export default function EWSAlertLayer({ }; }, [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 && ( +
+
+
+
+
+
+
+ PERINGATAN + {currentAlert.category || "Emergency Alert"} +
+
+
+
+
+
+
+
+
+
+

{currentAlert.priority}

+

PRIORITY

+
+
+
+

{currentAlert.location.district}

+

LOCATION

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ )} + + ); } diff --git a/sigap-website/app/_styles/ews.css b/sigap-website/app/_styles/ews.css index bcd2ff6..c78b599 100644 --- a/sigap-website/app/_styles/ews.css +++ b/sigap-website/app/_styles/ews.css @@ -48,3 +48,98 @@ #ews-status-indicator { 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); } +} \ No newline at end of file diff --git a/sigap-website/app/_utils/map/custom-animated-popup.tsx b/sigap-website/app/_utils/map/custom-animated-popup.tsx new file mode 100644 index 0000000..567c33d --- /dev/null +++ b/sigap-website/app/_utils/map/custom-animated-popup.tsx @@ -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); + } +} diff --git a/sigap-website/package-lock.json b/sigap-website/package-lock.json index 81235b1..c36ac35 100644 --- a/sigap-website/package-lock.json +++ b/sigap-website/package-lock.json @@ -47,6 +47,7 @@ "input-otp": "^1.4.2", "lucide-react": "^0.468.0", "mapbox-gl": "^3.11.0", + "mapbox-gl-animated-popup": "^0.4.0", "ml-kmeans": "^6.0.0", "motion": "^12.4.7", "next": "latest", @@ -11261,6 +11262,15 @@ "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": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/marchingsquares/-/marchingsquares-1.3.3.tgz", diff --git a/sigap-website/package.json b/sigap-website/package.json index f8db003..b153153 100644 --- a/sigap-website/package.json +++ b/sigap-website/package.json @@ -53,6 +53,7 @@ "input-otp": "^1.4.2", "lucide-react": "^0.468.0", "mapbox-gl": "^3.11.0", + "mapbox-gl-animated-popup": "^0.4.0", "ml-kmeans": "^6.0.0", "motion": "^12.4.7", "next": "latest",