leaflet
Version:
JavaScript library for mobile-friendly interactive maps
200 lines (159 loc) • 5.59 kB
JavaScript
import {Handler} from '../../core/Handler.js';
import {Draggable} from '../../dom/Draggable.js';
import {LatLngBounds} from '../../geo/LatLngBounds.js';
import {Bounds} from '../../geometry/Bounds.js';
/** @import {DragEndEvent, LeafletEvent} from '../../types.js' */
/** @import {Point} from '../../geometry/Point.js' */
// Makes the map draggable (with panning inertia), enabled by default.
export class DragHandler extends Handler {
addHooks() {
if (!this._draggable) {
const map = this._map;
/** @type {Draggable} */
this._draggable = new Draggable(map._mapPane, map._container);
this._draggable.on({
down: this._onDown,
dragstart: this._onDragStart,
drag: this._onDrag,
dragend: this._onDragEnd
}, this);
this._draggable.on('predrag', this._onPreDragLimit, this);
if (map.options.worldCopyJump) {
this._draggable.on('predrag', this._onPreDragWrap, this);
}
}
this._map._container.classList.add('leaflet-grab', 'leaflet-touch-drag');
this._draggable.enable();
/** @type {Point[]} */
this._positions = [];
/** @type {number[]} */
this._times = [];
}
removeHooks() {
this._map._container.classList.remove('leaflet-grab', 'leaflet-touch-drag');
this._draggable.disable();
}
/** Returns `true` if the current or last drag moved the map, e.g. to ignore the click that ends a drag. */
moved() {
return this._draggable?._moved;
}
/** Returns `true` while the map is being dragged. */
moving() {
return this._draggable?._moving;
}
_onDown() {
// Stop inertia before Draggable captures _startPos (#10376).
this._map._stop();
}
_onDragStart() {
const map = this._map;
map._stop();
if (this._map.options.maxBounds && this._map.options.maxBoundsViscosity) {
const bounds = new LatLngBounds(this._map.options.maxBounds);
/** @type {Bounds | null} */
this._offsetLimit = new Bounds(
this._map.latLngToContainerPoint(bounds.getNorthWest()).multiplyBy(-1),
this._map.latLngToContainerPoint(bounds.getSouthEast()).multiplyBy(-1)
.add(this._map.getSize()));
/** @type {number} */
this._viscosity = Math.min(1.0, Math.max(0.0, this._map.options.maxBoundsViscosity));
} else {
this._offsetLimit = null;
}
map
.fire('movestart')
.fire('dragstart');
if (map.options.inertia) {
this._positions = [];
this._times = [];
}
}
/** @param {LeafletEvent} e */
_onDrag(e) {
if (this._map.options.inertia) {
const time = Date.now(),
pos = this._draggable._absPos || this._draggable._newPos;
/** @type {number} */
this._lastTime = time;
/** @type {Point} */
this._lastPos = pos;
this._positions.push(pos);
this._times.push(time);
this._prunePositions(time);
}
this._map
.fire('move', e)
.fire('drag', e);
}
/** @param {number} time */
_prunePositions(time) {
while (this._positions.length > 1 && time - this._times[0] > 50) {
this._positions.shift();
this._times.shift();
}
}
/**
* @param {number} value
* @param {number} threshold
*/
_viscousLimit(value, threshold) {
return value - (value - threshold) * this._viscosity;
}
_onPreDragLimit() {
if (!this._viscosity || !this._offsetLimit) { return; }
const offset = this._draggable._newPos.subtract(this._draggable._startPos);
const limit = this._offsetLimit;
if (offset.x < limit.min.x) { offset.x = this._viscousLimit(offset.x, limit.min.x); }
if (offset.y < limit.min.y) { offset.y = this._viscousLimit(offset.y, limit.min.y); }
if (offset.x > limit.max.x) { offset.x = this._viscousLimit(offset.x, limit.max.x); }
if (offset.y > limit.max.y) { offset.y = this._viscousLimit(offset.y, limit.max.y); }
this._draggable._newPos = this._draggable._startPos.add(offset);
}
_onPreDragWrap() {
const draggable = this._draggable;
draggable._absPos = draggable._newPos;
draggable._newPos = this._map._wrapWorldCopy(draggable._newPos);
}
/** @param {DragEndEvent} e */
_onDragEnd(e) {
const map = this._map,
options = map.options,
noInertia = !options.inertia || e.noInertia || this._times.length < 2;
map.fire('dragend', e);
if (noInertia) {
map.fire('moveend');
} else {
this._prunePositions(Date.now());
const direction = this._lastPos.subtract(this._positions[0]),
duration = (this._lastTime - this._times[0]) / 1000,
ease = options.easeLinearity,
speedVector = direction.multiplyBy(ease / duration),
speed = speedVector.distanceTo([0, 0]),
limitedSpeed = Math.min(options.inertiaMaxSpeed, speed),
limitedSpeedVector = speedVector.multiplyBy(limitedSpeed / speed),
maxOffset = map.getSize().divideBy(2);
let decelerationDuration = limitedSpeed / (options.inertiaDeceleration * ease),
offset = limitedSpeedVector.multiplyBy(-decelerationDuration / 2).round();
// with reduced motion, shorten the coast to fit within half the map size; distance grows with the
// square of duration, so scaling the duration by the square root keeps the deceleration the same
if (map._prefersReducedMotion() && !maxOffset.contains(offset)) {
const k = Math.min(maxOffset.x / Math.abs(offset.x), maxOffset.y / Math.abs(offset.y));
offset = offset.multiplyBy(k).round();
decelerationDuration *= Math.sqrt(k);
}
if (!offset.x && !offset.y) {
map.fire('moveend');
} else {
offset = map._limitOffset(offset, map.options.maxBounds);
requestAnimationFrame(() => {
map.panBy(offset, {
duration: decelerationDuration,
easeLinearity: ease,
noMoveStart: true,
animate: true
});
});
}
}
}
}