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, showCenter?: boolean } = {}): void { const { color = 'red', maxRadius = 80, // Reduce max radius for less "over" effect duration = 2000, // Faster animation count = 2, // Fewer circles showCenter = true } = options; 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 } }] } }); 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, showCenter ? 0.15 : 0, // Lower opacity 100, 0 ], 'circle-stroke-width': 1.5, // Thinner stroke 'circle-stroke-color': color } }); 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); } }