324 lines
9.2 KiB
TypeScript
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);
|
|
}
|
|
}
|