//This code is 100% based on https://github.com/jawj/OverlappingMarkerSpiderfier-Leaflet
//Huge thanks to jawj for implementing it first to make my job easy :-)
L.MarkerCluster.include({
_2PI: Math.PI * 2,
_circleFootSeparation: 25, //related to circumference of circle
_circleStartAngle: Math.PI / 6,
_spiralFootSeparation: 28, //related to size of spiral (experiment!)
_spiralLengthStart: 11,
_spiralLengthFactor: 5,
_circleSpiralSwitchover: 9, //show spiral instead of circle from this marker count upwards.
// 0 -> always spiral; Infinity -> always circle
spiderfy: function () {
if (this._group._spiderfied === this || this._group._inZoomAnimation) {
return;
}
var childMarkers = this.getAllChildMarkers(),
group = this._group,
map = group._map,
center = map.latLngToLayerPoint(this._latlng),
positions;
this._group._unspiderfy();
this._group._spiderfied = this;
//TODO Maybe: childMarkers order by distance to center
if (childMarkers.length >= this._circleSpiralSwitchover) {
positions = this._generatePointsSpiral(childMarkers.length, center);
} else {
center.y += 10; //Otherwise circles look wrong
positions = this._generatePointsCircle(childMarkers.length, center);
}
this._animationSpiderfy(childMarkers, positions);
},
unspiderfy: function (zoomDetails) {
/// Argument from zoomanim if being called in a zoom animation or null otherwise
if (this._group._inZoomAnimation) {
return;
}
this._animationUnspiderfy(zoomDetails);
this._group._spiderfied = null;
},
_generatePointsCircle: function (count, centerPt) {
var circumference = this._group.options.spiderfyDistanceMultiplier * this._circleFootSeparation * (2 + count),
legLength = circumference / this._2PI, //radius from circumference
angleStep = this._2PI / count,
res = [],
i, angle;
res.length = count;
for (i = count - 1; i >= 0; i--) {
angle = this._circleStartAngle + i * angleStep;
res[i] = new L.Point(centerPt.x + legLength * Math.cos(angle), centerPt.y + legLength * Math.sin(angle))._round();
}
return res;
},
_generatePointsSpiral: function (count, centerPt) {
var legLength = this._group.options.spiderfyDistanceMultiplier * this._spiralLengthStart,
separation = this._group.options.spiderfyDistanceMultiplier * this._spiralFootSeparation,
lengthFactor = this._group.options.spiderfyDistanceMultiplier * this._spiralLengthFactor,
angle = 0,
res = [],
i;
res.length = count;
for (i = count - 1; i >= 0; i--) {
angle += separation / legLength + i * 0.0005;
res[i] = new L.Point(centerPt.x + legLength * Math.cos(angle), centerPt.y + legLength * Math.sin(angle))._round();
legLength += this._2PI * lengthFactor / angle;
}
return res;
},
_noanimationUnspiderfy: function () {
var group = this._group,
map = group._map,
fg = group._featureGroup,
childMarkers = this.getAllChildMarkers(),
m, i;
this.setOpacity(1);
for (i = childMarkers.length - 1; i >= 0; i--) {
m = childMarkers[i];
fg.removeLayer(m);
if (m._preSpiderfyLatlng) {
m.setLatLng(m._preSpiderfyLatlng);
delete m._preSpiderfyLatlng;
}
if (m.setZIndexOffset) {
m.setZIndexOffset(0);
}
if (m._spiderLeg) {
map.removeLayer(m._spiderLeg);
delete m._spiderLeg;
}
}
group._spiderfied = null;
}
});
L.MarkerCluster.include(!L.DomUtil.TRANSITION ? {
//Non Animated versions of everything
_animationSpiderfy: function (childMarkers, positions) {
var group = this._group,
map = group._map,
fg = group._featureGroup,
i, m, leg, newPos;
for (i = childMarkers.length - 1; i >= 0; i--) {
newPos = map.layerPointToLatLng(positions[i]);
m = childMarkers[i];
m._preSpiderfyLatlng = m._latlng;
m.setLatLng(newPos);
if (m.setZIndexOffset) {
m.setZIndexOffset(1000000); //Make these appear on top of EVERYTHING
}
fg.addLayer(m);
var legOptions = this._group.options.spiderLegPolylineOptions;
leg = new L.Polyline([this._latlng, newPos], legOptions);
map.addLayer(leg);
m._spiderLeg = leg;
}
this.setOpacity(0.3);
group.fire('spiderfied');
},
_animationUnspiderfy: function () {
this._noanimationUnspiderfy();
}
} : {
//Animated versions here
SVG_ANIMATION: (function () {
return document.createElementNS('http://www.w3.org/2000/svg', 'animate').toString().indexOf('SVGAnimate') > -1;
}()),
_animationSpiderfy: function (childMarkers, positions) {
var me = this,
group = this._group,
map = group._map,
fg = group._featureGroup,
thisLayerPos = map.latLngToLayerPoint(this._latlng),
i, m, leg, newPos;
//Add markers to map hidden at our center point
for (i = childMarkers.length - 1; i >= 0; i--) {
m = childMarkers[i];
//If it is a marker, add it now and we'll animate it out
if (m.setOpacity) {
m.setZIndexOffset(1000000); //Make these appear on top of EVERYTHING
m.clusterHide();
fg.addLayer(m);
m._setPos(thisLayerPos);
} else {
//Vectors just get immediately added
fg.addLayer(m);
}
}
group._forceLayout();
group._animationStart();
var initialLegOpacity = L.Path.SVG ? 0 : 0.3,
xmlns = L.Path.SVG_NS;
for (i = childMarkers.length - 1; i >= 0; i--) {
newPos = map.layerPointToLatLng(positions[i]);
m = childMarkers[i];
//Move marker to new position
m._preSpiderfyLatlng = m._latlng;
m.setLatLng(newPos);
if (m.setOpacity) {
m.clusterShow();
}
//Add Legs.
var legOptions = this._group.options.spiderLegPolylineOptions;
if (legOptions.opacity === undefined) {
legOptions.opacity = initialLegOpacity;
}
leg = new L.Polyline([me._latlng, newPos], legOptions);
map.addLayer(leg);
m._spiderLeg = leg;
//Following animations don't work for canvas
if (!L.Path.SVG || !this.SVG_ANIMATION) {
continue;
}
//How this works:
//http://stackoverflow.com/questions/5924238/how-do-you-animate-an-svg-path-in-ios
//http://dev.opera.com/articles/view/advanced-svg-animation-techniques/
//Animate length
var length = leg._path.getTotalLength();
leg._path.setAttribute("stroke-dasharray", length + "," + length);
var anim = document.createElementNS(xmlns, "animate");
anim.setAttribute("attributeName", "stroke-dashoffset");
anim.setAttribute("begin", "indefinite");
anim.setAttribute("from", length);
anim.setAttribute("to", 0);
anim.setAttribute("dur", 0.25);
leg._path.appendChild(anim);
anim.beginElement();
//Animate opacity
anim = document.createElementNS(xmlns, "animate");
anim.setAttribute("attributeName", "stroke-opacity");
anim.setAttribute("attributeName", "stroke-opacity");
anim.setAttribute("begin", "indefinite");
anim.setAttribute("from", 0);
anim.setAttribute("to", 0.5);
anim.setAttribute("dur", 0.25);
leg._path.appendChild(anim);
anim.beginElement();
}
me.setOpacity(0.3);
//Set the opacity of the spiderLegs back to their correct value
// The animations above override this until they complete.
// If the initial opacity of the spiderlegs isn't 0 then they appear before the animation starts.
if (L.Path.SVG) {
this._group._forceLayout();
for (i = childMarkers.length - 1; i >= 0; i--) {
m = childMarkers[i]._spiderLeg;
m.options.opacity = 0.5;
m._path.setAttribute('stroke-opacity', 0.5);
}
}
setTimeout(function () {
group._animationEnd();
group.fire('spiderfied');
}, 200);
},
_animationUnspiderfy: function (zoomDetails) {
var group = this._group,
map = group._map,
fg = group._featureGroup,
thisLayerPos = zoomDetails ? map._latLngToNewLayerPoint(this._latlng, zoomDetails.zoom, zoomDetails.center) : map.latLngToLayerPoint(this._latlng),
childMarkers = this.getAllChildMarkers(),
svg = L.Path.SVG && this.SVG_ANIMATION,
m, i, a;
group._animationStart();
//Make us visible and bring the child markers back in
this.setOpacity(1);
for (i = childMarkers.length - 1; i >= 0; i--) {
m = childMarkers[i];
//Marker was added to us after we were spidified
if (!m._preSpiderfyLatlng) {
continue;
}
//Fix up the location to the real one
m.setLatLng(m._preSpiderfyLatlng);
delete m._preSpiderfyLatlng;
//Hack override the location to be our center
if (m.setOpacity) {
m._setPos(thisLayerPos);
m.clusterHide();
} else {
fg.removeLayer(m);
}
//Animate the spider legs back in
if (svg) {
a = m._spiderLeg._path.childNodes[0];
a.setAttribute('to', a.getAttribute('from'));
a.setAttribute('from', 0);
a.beginElement();
a = m._spiderLeg._path.childNodes[1];
a.setAttribute('from', 0.5);
a.setAttribute('to', 0);
a.setAttribute('stroke-opacity', 0);
a.beginElement();
m._spiderLeg._path.setAttribute('stroke-opacity', 0);
}
}
setTimeout(function () {
//If we have only <= one child left then that marker will be shown on the map so don't remove it!
var stillThereChildCount = 0;
for (i = childMarkers.length - 1; i >= 0; i--) {
m = childMarkers[i];
if (m._spiderLeg) {
stillThereChildCount++;
}
}
for (i = childMarkers.length - 1; i >= 0; i--) {
m = childMarkers[i];
if (!m._spiderLeg) { //Has already been unspiderfied
continue;
}
if (m.setOpacity) {
m.clusterShow();
m.setZIndexOffset(0);
}
if (stillThereChildCount > 1) {
fg.removeLayer(m);
}
map.removeLayer(m._spiderLeg);
delete m._spiderLeg;
}
group._animationEnd();
}, 200);
}
});
L.MarkerClusterGroup.include({
//The MarkerCluster currently spiderfied (if any)
_spiderfied: null,
_spiderfierOnAdd: function () {
this._map.on('click', this._unspiderfyWrapper, this);
if (this._map.options.zoomAnimation) {
this._map.on('zoomstart', this._unspiderfyZoomStart, this);
}
//Browsers without zoomAnimation or a big zoom don't fire zoomstart
this._map.on('zoomend', this._noanimationUnspiderfy, this);
if (L.Path.SVG && !L.Browser.touch) {
this._map._initPathRoot();
//Needs to happen in the pageload, not after, or animations don't work in webkit
// http://stackoverflow.com/questions/8455200/svg-animate-with-dynamically-added-elements
//Disable on touch browsers as the animation messes up on a touch zoom and isn't very noticable
}
},
_spiderfierOnRemove: function () {
this._map.off('click', this._unspiderfyWrapper, this);
this._map.off('zoomstart', this._unspiderfyZoomStart, this);
this._map.off('zoomanim', this._unspiderfyZoomAnim, this);
this._unspiderfy(); //Ensure that markers are back where they should be
},
//On zoom start we add a zoomanim handler so that we are guaranteed to be last (after markers are animated)
//This means we can define the animation they do rather than Markers doing an animation to their actual location
_unspiderfyZoomStart: function () {
if (!this._map) { //May have been removed from the map by a zoomEnd handler
return;
}
this._map.on('zoomanim', this._unspiderfyZoomAnim, this);
},
_unspiderfyZoomAnim: function (zoomDetails) {
//Wait until the first zoomanim after the user has finished touch-zooming before running the animation
if (L.DomUtil.hasClass(this._map._mapPane, 'leaflet-touching')) {
return;
}
this._map.off('zoomanim', this._unspiderfyZoomAnim, this);
this._unspiderfy(zoomDetails);
},
_unspiderfyWrapper: function () {
/// _unspiderfy but passes no arguments
this._unspiderfy();
},
_unspiderfy: function (zoomDetails) {
if (this._spiderfied) {
this._spiderfied.unspiderfy(zoomDetails);
}
},
_noanimationUnspiderfy: function () {
if (this._spiderfied) {
this._spiderfied._noanimationUnspiderfy();
}
},
//If the given layer is currently being spiderfied then we unspiderfy it so it isn't on the map anymore etc
_unspiderfyLayer: function (layer) {
if (layer._spiderLeg) {
this._featureGroup.removeLayer(layer);
layer.setOpacity(1);
//Position will be fixed up immediately in _animationUnspiderfy
layer.setZIndexOffset(0);
this._map.removeLayer(layer._spiderLeg);
delete layer._spiderLeg;
}
}
});