/**
* BeNomad BeMap JavaScript API - MapLibre v5 - Animation
*
* Usage:
* // First add a GeoJSON source for the animated point
* map.addGeoJsonSource('moving-point', {
* type: 'Feature',
* geometry: { type: 'Point', coordinates: [0, 0] }
* });
*
* // Then animate along a route
* var controller = map.animateAlongRoute({
* sourceId: 'moving-point',
* coordinates: [[2.35, 48.85], [7.26, 43.71], [5.37, 43.29]],
* speed: 0.005,
* loop: true,
* onUpdate: function(pos) { console.log(pos.lon, pos.lat); },
* onComplete: function() { console.log('Done!'); }
* });
*
* // Control the animation
* controller.stop();
* controller.resume();
*/
bemap.MapLibreMap.prototype.animateAlongRoute = function(options) {
var opts = options || {};
var route = opts.coordinates || [];
var speed = opts.speed || 0.005;
var loop = opts.loop !== false;
var sourceId = opts.sourceId;
var onUpdate = opts.onUpdate;
var onComplete = opts.onComplete;
var _this = this;
if (!route.length || !sourceId) {
if (!sourceId) {
console.warn('animateAlongRoute: opts.sourceId is required. Call map.addGeoJsonSource(sourceId, geojson) first.');
}
return { stop: function(){}, resume: function(){} };
}
// Defensive: warn if the customer didn't pre-add the source — the
// animation will silently no-op otherwise.
if (!_this.native.getSource(sourceId)) {
console.warn('animateAlongRoute: source "' + sourceId + '" not found on the map. Call map.addGeoJsonSource(sourceId, geojson) first.');
}
var currentPosition = 0;
var animating = true;
var animFrameId = null;
// Compute total route length for smooth interpolation
var totalSegments = route.length - 1;
function animate() {
if (!animating) return;
currentPosition += speed;
if (currentPosition >= totalSegments) {
if (loop) {
currentPosition = 0;
} else {
animating = false;
if (onComplete) onComplete();
return;
}
}
var index = Math.floor(currentPosition);
var next = Math.min(index + 1, route.length - 1);
var t = currentPosition - index;
// Interpolate position
var lon = route[index][0] + (route[next][0] - route[index][0]) * t;
var lat = route[index][1] + (route[next][1] - route[index][1]) * t;
// Update the GeoJSON source
var src = _this.native.getSource(sourceId);
if (src) {
src.setData({
type: 'Feature',
geometry: { type: 'Point', coordinates: [lon, lat] },
properties: { bearing: Math.atan2(route[next][0] - route[index][0], route[next][1] - route[index][1]) * 180 / Math.PI }
});
}
if (onUpdate) onUpdate({ lon: lon, lat: lat, progress: currentPosition / totalSegments });
animFrameId = requestAnimationFrame(animate);
}
animFrameId = requestAnimationFrame(animate);
var controller = {
stop: function() {
animating = false;
if (animFrameId) cancelAnimationFrame(animFrameId);
if (typeof _this._unregisterEffect === 'function') _this._unregisterEffect('animateAlongRoute:' + sourceId);
},
resume: function() {
if (!animating) {
animating = true;
animFrameId = requestAnimationFrame(animate);
}
},
reset: function() {
currentPosition = 0;
}
};
if (typeof _this._registerEffect === 'function') {
_this._registerEffect('animateAlongRoute:' + sourceId, function () { controller.stop(); });
}
return controller;
};
/**
* Animate drawing a line progressively. MapLibre only.
* @param {object} options
* @param {string} options.sourceId GeoJSON source ID (must exist).
* @param {Array} options.coordinates Array of [lon, lat] for the full line.
* @param {number} options.speed Points per frame (default: 1).
* @return {object} Controller { stop, resume, reset }
*/
bemap.MapLibreMap.prototype.animateLine = function(options) {
var opts = options || {};
var route = opts.coordinates || [];
var speed = opts.speed || 1;
var sourceId = opts.sourceId;
var onUpdate = opts.onUpdate;
var onComplete = opts.onComplete;
var _this = this;
if (!route.length || !sourceId) return { stop: function(){}, resume: function(){} };
var currentIndex = 0;
var animating = true;
var animFrameId = null;
function animate() {
if (!animating) return;
currentIndex = Math.min(currentIndex + speed, route.length);
var src = _this.native.getSource(sourceId);
if (src) {
src.setData({
type: 'Feature',
geometry: { type: 'LineString', coordinates: route.slice(0, Math.ceil(currentIndex)) }
});
}
if (onUpdate) onUpdate({ index: Math.ceil(currentIndex), total: route.length, progress: currentIndex / route.length });
if (currentIndex >= route.length) {
animating = false;
if (onComplete) onComplete();
return;
}
animFrameId = requestAnimationFrame(animate);
}
animFrameId = requestAnimationFrame(animate);
return {
stop: function() { animating = false; if (animFrameId) cancelAnimationFrame(animFrameId); },
resume: function() { if (!animating) { animating = true; animFrameId = requestAnimationFrame(animate); } },
reset: function() { currentIndex = 0; }
};
};
/**
* Animate camera rotation around a point. MapLibre only.
* @param {object} options
* @param {Array} options.center [lon, lat] rotation center.
* @param {number} options.zoom Zoom level (default: current).
* @param {number} options.pitch Pitch angle (default: 60).
* @param {number} options.speed Degrees per frame (default: 0.3).
* @return {object} Controller { stop, resume }
*/
bemap.MapLibreMap.prototype.animateCameraOrbit = function(options) {
var opts = options || {};
var center = opts.center || [0, 0];
var zoomLevel = opts.zoom || this.native.getZoom();
var pitch = opts.pitch !== undefined ? opts.pitch : 60;
var speed = opts.speed || 0.3;
var _this = this;
var bearing = this.native.getBearing();
var animating = true;
var animFrameId = null;
function orbit() {
if (!animating) return;
bearing += speed;
if (bearing > 360) bearing -= 360;
_this.native.jumpTo({ center: center, zoom: zoomLevel, pitch: pitch, bearing: bearing });
animFrameId = requestAnimationFrame(orbit);
}
animFrameId = requestAnimationFrame(orbit);
return {
stop: function() { animating = false; if (animFrameId) cancelAnimationFrame(animFrameId); },
resume: function() { if (!animating) { animating = true; animFrameId = requestAnimationFrame(orbit); } }
};
};
/**
* Animate pulsing circle effect. MapLibre only.
* Creates a pulsing circle at a coordinate using paint property animation.
* @param {object} options
* @param {Array} options.center [lon, lat].
* @param {string} options.color Circle color (default: '#e74c3c').
* @param {number} options.maxRadius Max radius in pixels (default: 30).
* @param {number} options.speed Pulse speed (default: 0.05).
* @return {object} Controller { stop, remove }
*/
bemap.MapLibreMap.prototype.animatePulse = function(options) {
var opts = options || {};
var center = opts.center || [0, 0];
var color = opts.color || '#e74c3c';
var maxRadius = opts.maxRadius || 30;
var speed = opts.speed || 0.05;
var _this = this;
var sourceId = this._uniqueId('pulse-src');
var layerId = this._uniqueId('pulse-layer');
var phase = 0;
var animating = true;
var animFrameId = null;
// Add source + layer
var addPulse = function() {
if (!_this.native.getSource(sourceId)) {
_this.native.addSource(sourceId, {
type: 'geojson',
data: { type: 'Feature', geometry: { type: 'Point', coordinates: center } }
});
_this.native.addLayer({
id: layerId, type: 'circle', source: sourceId,
paint: { 'circle-radius': 10, 'circle-color': color, 'circle-opacity': 0.8, 'circle-stroke-width': 2, 'circle-stroke-color': color }
});
}
};
if (this.native.isStyleLoaded()) { addPulse(); } else { this.native.once('load', addPulse); }
function pulse() {
if (!animating) return;
phase += speed;
var t = (Math.sin(phase) + 1) / 2; // 0 to 1
var radius = 5 + t * maxRadius;
var opacity = 1 - t * 0.7;
try {
_this.native.setPaintProperty(layerId, 'circle-radius', radius);
_this.native.setPaintProperty(layerId, 'circle-opacity', opacity);
} catch(e) {}
animFrameId = requestAnimationFrame(pulse);
}
animFrameId = requestAnimationFrame(pulse);
return {
stop: function() { animating = false; if (animFrameId) cancelAnimationFrame(animFrameId); },
remove: function() {
animating = false;
if (animFrameId) cancelAnimationFrame(animFrameId);
try {
if (_this.native.getLayer(layerId)) _this.native.removeLayer(layerId);
if (_this.native.getSource(sourceId)) _this.native.removeSource(sourceId);
} catch(e) {}
}
};
};
/**
* Spin the globe continuously. MapLibre only.
* @param {object} options
* @param {number} options.speed Degrees per frame (default: 0.3).
* @return {object} Controller { stop, resume }
*/
bemap.MapLibreMap.prototype.spinGlobe = function(options) {
var opts = options || {};
var speed = opts.speed || 0.3;
var _this = this;
var animating = true;
// Use both setInterval AND rAF so the timer ID is observable for
// teardown verification and rAF gets paused when the tab backgrounds.
// The instance-level `_globeSpinTimer` ID makes destroy() cleanup
// possible (see remove() in bemap-map-maplibre.js).
this._globeSpinTimer = setInterval(function () {
if (!animating || !_this.native) return;
var center = _this.native.getCenter();
_this.native.jumpTo({ center: [center.lng + speed, center.lat] });
}, 50);
// Register in the active-effects registry so the dashboard can tear
// it down via clearAllEffects() — fixes Bug 11.
if (typeof this._registerEffect === 'function') {
this._registerEffect('spinGlobe', function () { _this.stopSpinGlobe(); });
}
return {
stop: function () { _this.stopSpinGlobe(); },
resume: function () { if (!_this._globeSpinTimer) _this.spinGlobe(opts); }
};
};
bemap.MapLibreMap.prototype.stopSpinGlobe = function () {
if (this._globeSpinTimer) {
clearInterval(this._globeSpinTimer);
this._globeSpinTimer = null;
}
if (typeof this._unregisterEffect === 'function') this._unregisterEffect('spinGlobe');
return this;
};