Skip to content

Commit 808163f

Browse files
swtormyConengmo
andauthored
Add OverlappingMarkerSpiderfier Plugin (#2033)
* feat(plugins): add OverlappingMarkerSpiderfier plugin for handling overlapping markers * docs(plugins): update overlapping_marker_spiderfier to align with plugin conventions * docs(overlapping_marker_spiderfier): add class description * refactor(plugin): simplify marker and popup handling in OMS * fix: resolve pre-commit issues * Update folium/plugins/overlapping_marker_spiderfier.py Co-authored-by: Frank Anema <[email protected]> * Update folium/plugins/overlapping_marker_spiderfier.py Co-authored-by: Frank Anema <[email protected]> * feat: add support for spiderifying markers in FeatureGroups * docs: modification of OverlappingMarkerSpiderfier plugin documentation * Update folium/plugins/overlapping_marker_spiderfier.py --------- Co-authored-by: Frank Anema <[email protected]>
1 parent 1f2d67b commit 808163f

File tree

5 files changed

+329
-0
lines changed

5 files changed

+329
-0
lines changed

docs/user_guide/plugins.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Plugins
2121
plugins/mini_map
2222
plugins/measure_control
2323
plugins/mouse_position
24+
plugins/overlapping_marker_spiderfier
2425
plugins/pattern
2526
plugins/polygon_encoded
2627
plugins/polyline_encoded
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# OverlappingMarkerSpiderfier
2+
3+
The `OverlappingMarkerSpiderfier` is a plugin for Folium that helps manage overlapping markers by "spiderfying" them when clicked, making it easier to select individual markers.
4+
5+
## Using with Markers
6+
7+
```{code-cell} ipython3
8+
import folium
9+
from folium.plugins import OverlappingMarkerSpiderfier
10+
11+
# Create a map
12+
m = folium.Map(location=[45.05, 3.05], zoom_start=13)
13+
14+
# Add markers to the map
15+
for i in range(20):
16+
folium.Marker(
17+
location=[45.05 + i * 0.0001, 3.05 + i * 0.0001],
18+
popup=f"Marker {i}"
19+
).add_to(m)
20+
21+
# Add the OverlappingMarkerSpiderfier plugin
22+
oms = OverlappingMarkerSpiderfier(
23+
keep_spiderfied=True, # Markers remain spiderfied after clicking
24+
nearby_distance=20, # Distance for clustering markers in pixel
25+
circle_spiral_switchover=10, # Threshold for switching between circle and spiral
26+
leg_weight=2.0 # Line thickness for spider legs
27+
)
28+
oms.add_to(m)
29+
30+
m
31+
```
32+
33+
## Using with FeatureGroups
34+
35+
```{code-cell} ipython3
36+
import folium
37+
from folium.plugins import OverlappingMarkerSpiderfier
38+
39+
# Create a map
40+
m = folium.Map(location=[45.05, 3.05], zoom_start=13)
41+
42+
# Create a FeatureGroup
43+
feature_group = folium.FeatureGroup(name='Feature Group')
44+
45+
# Add markers to the FeatureGroup
46+
for i in range(10):
47+
folium.Marker(
48+
location=[45.05 + i * 0.0001, 3.05 + i * 0.0001],
49+
popup=f"Feature Group Marker {i}"
50+
).add_to(feature_group)
51+
52+
# Add the FeatureGroup to the map
53+
feature_group.add_to(m)
54+
55+
# Initialize OverlappingMarkerSpiderfier
56+
oms = OverlappingMarkerSpiderfier()
57+
oms.add_to(m)
58+
59+
m
60+
```
61+
62+
## Using with FeatureGroupSubGroups
63+
64+
```{code-cell} ipython3
65+
import folium
66+
from folium.plugins import OverlappingMarkerSpiderfier, FeatureGroupSubGroup
67+
68+
# Create a map
69+
m = folium.Map(location=[45.05, 3.05], zoom_start=13)
70+
71+
# Create a main FeatureGroup
72+
main_group = folium.FeatureGroup(name='Main Group')
73+
74+
# Create sub-groups
75+
sub_group1 = FeatureGroupSubGroup(main_group, name='Sub Group 1')
76+
sub_group2 = FeatureGroupSubGroup(main_group, name='Sub Group 2')
77+
78+
# Add markers to the first sub-group
79+
for i in range(10):
80+
folium.Marker(
81+
location=[45.05 + i * 0.0001, 3.05 + i * 0.0001],
82+
popup=f"Sub Group 1 Marker {i}"
83+
).add_to(sub_group1)
84+
85+
# Add markers to the second sub-group
86+
for i in range(10, 20):
87+
folium.Marker(
88+
location=[45.06 + (i - 10) * 0.0001, 3.06 + (i - 10) * 0.0001],
89+
popup=f"Sub Group 2 Marker {i}"
90+
).add_to(sub_group2)
91+
92+
# Add the main group to the map
93+
main_group.add_to(m)
94+
95+
# Add sub-groups to the map
96+
sub_group1.add_to(m)
97+
sub_group2.add_to(m)
98+
99+
# Initialize OverlappingMarkerSpiderfier
100+
oms = OverlappingMarkerSpiderfier()
101+
oms.add_to(m)
102+
103+
# Add the LayerControl plugin
104+
folium.LayerControl().add_to(m)
105+
106+
m
107+
```

folium/plugins/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from folium.plugins.measure_control import MeasureControl
2020
from folium.plugins.minimap import MiniMap
2121
from folium.plugins.mouse_position import MousePosition
22+
from folium.plugins.overlapping_marker_spiderfier import OverlappingMarkerSpiderfier
2223
from folium.plugins.pattern import CirclePattern, StripePattern
2324
from folium.plugins.polyline_offset import PolyLineOffset
2425
from folium.plugins.polyline_text_path import PolyLineTextPath
@@ -56,6 +57,7 @@
5657
"MeasureControl",
5758
"MiniMap",
5859
"MousePosition",
60+
"OverlappingMarkerSpiderfier",
5961
"PolygonFromEncoded",
6062
"PolyLineFromEncoded",
6163
"PolyLineTextPath",
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from typing import Optional
2+
3+
from jinja2 import Template
4+
5+
from folium.elements import Element, JSCSSMixin, MacroElement
6+
from folium.map import Marker
7+
from folium.utilities import parse_options
8+
9+
10+
class OverlappingMarkerSpiderfier(JSCSSMixin, MacroElement):
11+
"""
12+
A plugin that handles overlapping markers on a map by spreading them out in a spiral or circle pattern when clicked.
13+
14+
This plugin is useful when you have multiple markers in close proximity that would otherwise be difficult to interact with.
15+
When a user clicks on a cluster of overlapping markers, they spread out in a 'spider' pattern, making each marker
16+
individually accessible.
17+
18+
Markers are automatically identified and managed by the plugin, so there is no need to add them separately.
19+
Simply add the plugin to the map using `oms.add_to(map)`.
20+
21+
Parameters
22+
----------
23+
keep_spiderfied : bool, default True
24+
If true, markers stay spiderfied after clicking.
25+
nearby_distance : int, default 20
26+
Pixels away from a marker that is considered overlapping.
27+
leg_weight : float, default 1.5
28+
Weight of the spider legs.
29+
circle_spiral_switchover : int, default 9
30+
Number of markers at which to switch from circle to spiral pattern.
31+
32+
Example
33+
-------
34+
>>> oms = OverlappingMarkerSpiderfier(
35+
... keep_spiderfied=True, nearby_distance=30, leg_weight=2.0
36+
... )
37+
>>> oms.add_to(map)
38+
"""
39+
40+
_template = Template(
41+
"""
42+
{% macro script(this, kwargs) %}
43+
(function () {
44+
try {
45+
var oms = new OverlappingMarkerSpiderfier(
46+
{{ this._parent.get_name() }},
47+
{{ this.options|tojson }}
48+
);
49+
50+
oms.addListener('spiderfy', function() {
51+
{{ this._parent.get_name() }}.closePopup();
52+
});
53+
54+
{%- for marker in this.markers %}
55+
oms.addMarker({{ marker.get_name() }});
56+
{%- endfor %}
57+
} catch (error) {
58+
console.error('Error initializing OverlappingMarkerSpiderfier:', error);
59+
}
60+
})();
61+
{% endmacro %}
62+
"""
63+
)
64+
65+
default_js = [
66+
(
67+
"overlappingmarkerjs",
68+
"https://cdnjs.cloudflare.com/ajax/libs/OverlappingMarkerSpiderfier-Leaflet/0.2.6/oms.min.js",
69+
)
70+
]
71+
72+
def __init__(
73+
self,
74+
keep_spiderfied: bool = True,
75+
nearby_distance: int = 20,
76+
leg_weight: float = 1.5,
77+
circle_spiral_switchover: int = 9,
78+
**kwargs
79+
):
80+
super().__init__()
81+
self._name = "OverlappingMarkerSpiderfier"
82+
self.options = parse_options(
83+
keep_spiderfied=keep_spiderfied,
84+
nearby_distance=nearby_distance,
85+
leg_weight=leg_weight,
86+
circle_spiral_switchover=circle_spiral_switchover,
87+
**kwargs
88+
)
89+
90+
def add_to(
91+
self, parent: Element, name: Optional[str] = None, index: Optional[int] = None
92+
) -> Element:
93+
self._parent = parent
94+
self.markers = self._get_all_markers(parent)
95+
super().add_to(parent, name=name, index=index)
96+
97+
def _get_all_markers(self, element: Element) -> list:
98+
markers = []
99+
for child in element._children.values():
100+
if isinstance(child, Marker):
101+
markers.append(child)
102+
elif hasattr(child, "_children"):
103+
markers.extend(self._get_all_markers(child))
104+
return markers
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""
2+
Test OverlappingMarkerSpiderfier
3+
--------------------------------
4+
"""
5+
6+
import numpy as np
7+
8+
from folium.folium import Map
9+
from folium.map import Marker
10+
from folium.plugins.overlapping_marker_spiderfier import OverlappingMarkerSpiderfier
11+
12+
13+
def test_oms_js_inclusion():
14+
"""
15+
Test that the OverlappingMarkerSpiderfier JavaScript library is included in the map.
16+
"""
17+
m = Map([45.05, 3.05], zoom_start=14)
18+
OverlappingMarkerSpiderfier().add_to(m)
19+
20+
rendered_map = m._parent.render()
21+
assert (
22+
'<script src="https://cdnjs.cloudflare.com/ajax/libs/OverlappingMarkerSpiderfier-Leaflet/0.2.6/oms.min.js"></script>'
23+
in rendered_map
24+
), "OverlappingMarkerSpiderfier JS file is missing in the rendered output."
25+
26+
27+
def test_marker_addition():
28+
"""
29+
Test that markers are correctly added to the map.
30+
"""
31+
N = 10
32+
np.random.seed(seed=26082009)
33+
data = np.array(
34+
[
35+
np.random.uniform(low=45.0, high=45.1, size=N),
36+
np.random.uniform(low=3.0, high=3.1, size=N),
37+
]
38+
).T
39+
40+
m = Map([45.05, 3.05], zoom_start=14)
41+
markers = [
42+
Marker(
43+
location=loc,
44+
popup=f"Marker {i}",
45+
)
46+
for i, loc in enumerate(data)
47+
]
48+
49+
for marker in markers:
50+
marker.add_to(m)
51+
52+
assert (
53+
len(m._children) == len(markers) + 1
54+
), f"Expected {len(markers)} markers, found {len(m._children) - 1}."
55+
56+
57+
def test_map_bounds():
58+
"""
59+
Test that the map bounds correctly encompass all added markers.
60+
"""
61+
N = 10
62+
np.random.seed(seed=26082009)
63+
data = np.array(
64+
[
65+
np.random.uniform(low=45.0, high=45.1, size=N),
66+
np.random.uniform(low=3.0, high=3.1, size=N),
67+
]
68+
).T
69+
70+
m = Map([45.05, 3.05], zoom_start=14)
71+
markers = [
72+
Marker(
73+
location=loc,
74+
popup=f"Marker {i}",
75+
)
76+
for i, loc in enumerate(data)
77+
]
78+
79+
for marker in markers:
80+
marker.add_to(m)
81+
82+
bounds = m.get_bounds()
83+
assert bounds is not None, "Map bounds should not be None"
84+
85+
min_lat, min_lon = data.min(axis=0)
86+
max_lat, max_lon = data.max(axis=0)
87+
88+
assert (
89+
bounds[0][0] <= min_lat
90+
), "Map bounds do not correctly include the minimum latitude."
91+
assert (
92+
bounds[0][1] <= min_lon
93+
), "Map bounds do not correctly include the minimum longitude."
94+
assert (
95+
bounds[1][0] >= max_lat
96+
), "Map bounds do not correctly include the maximum latitude."
97+
assert (
98+
bounds[1][1] >= max_lon
99+
), "Map bounds do not correctly include the maximum longitude."
100+
101+
102+
def test_overlapping_marker_spiderfier_integration():
103+
"""
104+
Test that OverlappingMarkerSpiderfier integrates correctly with the map.
105+
"""
106+
m = Map([45.05, 3.05], zoom_start=14)
107+
oms = OverlappingMarkerSpiderfier(
108+
keep_spiderfied=True,
109+
nearby_distance=20,
110+
)
111+
oms.add_to(m)
112+
113+
assert (
114+
oms.get_name() in m._children
115+
), "OverlappingMarkerSpiderfier is not correctly added to the map."

0 commit comments

Comments
 (0)