MIF_E31221222/sigap-website/app/_utils/map/custom-animated-popup.tsx

324 lines
9.2 KiB
TypeScript

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);
}
}