Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/user_guide/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Plugins
plugins/mini_map
plugins/measure_control
plugins/mouse_position
plugins/overlapping_marker_spiderfier
plugins/pattern
plugins/polygon_encoded
plugins/polyline_encoded
Expand Down
24 changes: 24 additions & 0 deletions docs/user_guide/plugins/overlapping_marker_spiderfier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# OverlappingMarkerSpiderfier

```{code-cell} ipython3
import folium
from folium import plugins

# Create a map
m = folium.Map(location=[45.05, 3.05], zoom_start=14)

# Generate some markers
markers = [folium.Marker(location=[45.05 + i * 0.0001, 3.05 + i * 0.0001], options={'desc': f'Marker {i}'}) for i in range(10)]

# Add markers to the map
for marker in markers:
marker.add_to(m)

# Add OverlappingMarkerSpiderfier
oms = plugins.OverlappingMarkerSpiderfier(
markers=markers,
options={'keepSpiderfied': True, 'nearbyDistance': 20}
).add_to(m)

# Display the map
m
2 changes: 2 additions & 0 deletions folium/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from folium.plugins.measure_control import MeasureControl
from folium.plugins.minimap import MiniMap
from folium.plugins.mouse_position import MousePosition
from folium.plugins.overlapping_marker_spiderfier import OverlappingMarkerSpiderfier
from folium.plugins.pattern import CirclePattern, StripePattern
from folium.plugins.polyline_offset import PolyLineOffset
from folium.plugins.polyline_text_path import PolyLineTextPath
Expand Down Expand Up @@ -56,6 +57,7 @@
"MeasureControl",
"MiniMap",
"MousePosition",
"OverlappingMarkerSpiderfier",
"PolygonFromEncoded",
"PolyLineFromEncoded",
"PolyLineTextPath",
Expand Down
131 changes: 131 additions & 0 deletions folium/plugins/overlapping_marker_spiderfier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
from jinja2 import Template

from folium.elements import JSCSSMixin
from folium.map import Layer
from folium.utilities import parse_options


class OverlappingMarkerSpiderfier(JSCSSMixin, Layer):
"""A plugin that handles overlapping markers by spreading them into a spider-like pattern.

This plugin uses the OverlappingMarkerSpiderfier-Leaflet library to manage markers
that are close to each other or overlap. When clicked, the overlapping markers
spread out in a spiral pattern, making them easier to select individually.

Parameters
----------
markers : list, optional
List of markers to be managed by the spiderfier
name : string, optional
Name of the layer control
overlay : bool, default True
Whether the layer will be included in LayerControl
control : bool, default True
Whether the layer will be included in LayerControl
show : bool, default True
Whether the layer will be shown on opening
options : dict, optional
Additional options to be passed to the OverlappingMarkerSpiderfier instance
See https://github.com/jawj/OverlappingMarkerSpiderfier-Leaflet for available options

Example
-------
>>> markers = [marker1, marker2, marker3] # Create some markers
>>> spiderfier = OverlappingMarkerSpiderfier(
... markers=markers, keepSpiderfied=True, nearbyDistance=20
... )
>>> spiderfier.add_to(m) # Add to your map
"""

_template = Template(
"""
{% macro script(this, kwargs) %}
var {{ this.get_name() }} = (function () {
Copy link
Collaborator

@hansthen hansthen Nov 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really like how you used an IFFE here. I think we should use these for all our templates.

Copy link
Contributor Author

@swtormy swtormy Nov 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi! I looked up this method in FastMarkerCluster, so at least one plugin already uses IFFE)

var layerGroup = L.layerGroup();

try {
var oms = new OverlappingMarkerSpiderfier(
{{ this._parent.get_name() }},
{{ this.options|tojson }}
);

var popup = L.popup({
offset: L.point(0, -30)
});

oms.addListener('click', function(marker) {
var content;
if (marker.options && marker.options.options && marker.options.options.desc) {
content = marker.options.options.desc;
} else if (marker._popup && marker._popup._content) {
content = marker._popup._content;
} else {
content = "";
}

if (content) {
popup.setContent(content);
popup.setLatLng(marker.getLatLng());
{{ this._parent.get_name() }}.openPopup(popup);
}
});

oms.addListener('spiderfy', function(markers) {
{{ this._parent.get_name() }}.closePopup();
});

{% for marker in this.markers %}
var {{ marker.get_name() }} = L.marker(
{{ marker.location|tojson }},
{{ marker.options|tojson }}
);

{% if marker.popup %}
{{ marker.get_name() }}.bindPopup({{ marker.popup.get_content()|tojson }});
{% endif %}

oms.addMarker({{ marker.get_name() }});
layerGroup.addLayer({{ marker.get_name() }});
{% endfor %}
} catch (error) {
console.error('Error in OverlappingMarkerSpiderfier initialization:', error);
}

return layerGroup;
})();
{% endmacro %}

"""
)

default_js = [
(
"overlappingmarkerjs",
"https://cdnjs.cloudflare.com/ajax/libs/OverlappingMarkerSpiderfier-Leaflet/0.2.6/oms.min.js",
)
]

def __init__(
self,
markers=None,
name=None,
overlay=True,
control=True,
show=True,
options=None,
**kwargs,
):
super().__init__(name=name, overlay=overlay, control=control, show=show)
self._name = "OverlappingMarkerSpiderfier"

self.markers = markers or []

default_options = {
"keepSpiderfied": True,
"nearbyDistance": 20,
"legWeight": 1.5,
}
if options:
default_options.update(options)

self.options = parse_options(**default_options, **kwargs)
112 changes: 112 additions & 0 deletions tests/plugins/test_overlapping_marker_spiderfier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""
Test OverlappingMarkerSpiderfier
--------------------------------
"""

import numpy as np
from jinja2 import Template

import folium
from folium import plugins
from folium.utilities import normalize


def test_overlapping_marker_spiderfier():
N = 10
np.random.seed(seed=26082009)
data = np.array(
[
np.random.uniform(low=45.0, high=45.1, size=N),
np.random.uniform(low=3.0, high=3.1, size=N),
]
).T

m = folium.Map([45.05, 3.05], zoom_start=14)
markers = [
folium.Marker(location=loc, popup=f"Marker {i}") for i, loc in enumerate(data)
]

for marker in markers:
marker.add_to(m)

oms = plugins.OverlappingMarkerSpiderfier(
markers=markers, options={"keepSpiderfied": True, "nearbyDistance": 20}
).add_to(m)

tmpl_for_expected = Template(
"""
var {{this.get_name()}} = (function () {
var layerGroup = L.layerGroup();
try {
var oms = new OverlappingMarkerSpiderfier(
{{ this._parent.get_name() }},
{{ this.options|tojson }}
);

var popup = L.popup({
offset: L.point(0, -30)
});

oms.addListener('click', function(marker) {
var content;
if (marker.options && marker.options.options && marker.options.options.desc) {
content = marker.options.options.desc;
} else if (marker._popup && marker._popup._content) {
content = marker._popup._content;
} else {
content = "";
}

if (content) {
popup.setContent(content);
popup.setLatLng(marker.getLatLng());
{{ this._parent.get_name() }}.openPopup(popup);
}
});

oms.addListener('spiderfy', function(markers) {
{{ this._parent.get_name() }}.closePopup();
});

{% for marker in this.markers %}
var {{ marker.get_name() }} = L.marker(
{{ marker.location|tojson }},
{{ marker.options|tojson }}
);

{% if marker.popup %}
{{ marker.get_name() }}.bindPopup({{ marker.popup.get_content()|tojson }});
{% endif %}

oms.addMarker({{ marker.get_name() }});
layerGroup.addLayer({{ marker.get_name() }});
{% endfor %}
} catch (error) {
console.error('Error in OverlappingMarkerSpiderfier initialization:', error);
}

return layerGroup;
})();
"""
)
expected = normalize(tmpl_for_expected.render(this=oms))

out = normalize(m._parent.render())

assert (
'<script src="https://cdnjs.cloudflare.com/ajax/libs/OverlappingMarkerSpiderfier-Leaflet/0.2.6/oms.min.js"></script>'
in out
)

assert expected in out

bounds = m.get_bounds()
assert bounds is not None, "Map bounds should not be None"

min_lat, min_lon = data.min(axis=0)
max_lat, max_lon = data.max(axis=0)

assert bounds[0][0] <= min_lat
assert bounds[0][1] <= min_lon
assert bounds[1][0] >= max_lat
assert bounds[1][1] >= max_lon