Skip to content

Commit 5c245ef

Browse files
committed
feat(plugins): add OverlappingMarkerSpiderfier plugin for handling overlapping markers
1 parent f8bb159 commit 5c245ef

File tree

4 files changed

+273
-0
lines changed

4 files changed

+273
-0
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# OverlappingMarkerSpiderfier
2+
3+
The `OverlappingMarkerSpiderfier` plugin for Folium is designed to handle overlapping markers on a map. When multiple markers are located at the same or nearby coordinates, they can overlap, making it difficult to interact with individual markers. This plugin "spiderfies" the markers, spreading them out in a spider-like pattern, allowing users to easily click and view each marker.
4+
5+
## Features
6+
7+
- **Spiderfying**: Automatically spreads out overlapping markers into a spider-like pattern when clicked, making them individually accessible.
8+
- **Customizable Options**: Offers options to customize the behavior and appearance of the spiderfied markers, such as `keepSpiderfied`, `nearbyDistance`, and `legWeight`.
9+
- **Popup Integration**: Supports popups for each marker, which can be customized to display additional information.
10+
- **Layer Control**: Can be added as a layer to the map, allowing users to toggle its visibility.
11+
12+
## Usage
13+
14+
To use the `OverlappingMarkerSpiderfier`, you need to create a list of `folium.Marker` objects and pass them to the plugin. You can also customize the options to suit your needs.
15+
16+
### Example
17+
18+
```python
19+
import folium
20+
from folium import plugins
21+
22+
# Create a map
23+
m = folium.Map(location=[45.05, 3.05], zoom_start=14)
24+
25+
# Generate some markers
26+
markers = [folium.Marker(location=[45.05 + i * 0.0001, 3.05 + i * 0.0001], options={'desc': f'Marker {i}'}) for i in range(10)]
27+
28+
29+
# Add markers to the map
30+
for marker in markers:
31+
marker.add_to(m)
32+
33+
# Add OverlappingMarkerSpiderfier
34+
oms = plugins.OverlappingMarkerSpiderfier(
35+
markers=markers,
36+
options={'keepSpiderfied': True, 'nearbyDistance': 20}
37+
).add_to(m)
38+
39+
# Display the map
40+
m
41+
```
42+
43+
## Options
44+
45+
- **keepSpiderfied**: (bool) Whether to keep the markers spiderfied after clicking.
46+
- **nearbyDistance**: (int) The distance in pixels within which markers are considered overlapping.
47+
- **legWeight**: (float) The weight of the spider legs connecting the markers.
48+
49+
## Installation
50+
51+
Ensure you have Folium installed in your Python environment. You can install it using pip:
52+
53+
```bash
54+
pip install folium
55+
```
56+
57+
## Conclusion
58+
59+
The `OverlappingMarkerSpiderfier` plugin is a powerful tool for managing overlapping markers on a map, enhancing the user experience by making it easier to interact with individual markers. Customize it to fit your application's needs and improve the clarity of your map visualizations.

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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from jinja2 import Template
2+
3+
from folium.elements import JSCSSMixin
4+
from folium.map import Layer
5+
from folium.utilities import parse_options
6+
7+
8+
class OverlappingMarkerSpiderfier(JSCSSMixin, Layer):
9+
_template = Template(
10+
"""
11+
{% macro script(this, kwargs) %}
12+
var {{ this.get_name() }} = (function () {
13+
var layerGroup = L.layerGroup();
14+
15+
try {
16+
var oms = new OverlappingMarkerSpiderfier(
17+
{{ this._parent.get_name() }},
18+
{{ this.options|tojson }}
19+
);
20+
21+
var popup = L.popup({
22+
offset: L.point(0, -30)
23+
});
24+
25+
oms.addListener('click', function(marker) {
26+
var content;
27+
if (marker.options && marker.options.options && marker.options.options.desc) {
28+
content = marker.options.options.desc;
29+
} else if (marker._popup && marker._popup._content) {
30+
content = marker._popup._content;
31+
} else {
32+
content = "";
33+
}
34+
35+
if (content) {
36+
popup.setContent(content);
37+
popup.setLatLng(marker.getLatLng());
38+
{{ this._parent.get_name() }}.openPopup(popup);
39+
}
40+
});
41+
42+
oms.addListener('spiderfy', function(markers) {
43+
{{ this._parent.get_name() }}.closePopup();
44+
});
45+
46+
{% for marker in this.markers %}
47+
var {{ marker.get_name() }} = L.marker(
48+
{{ marker.location|tojson }},
49+
{{ marker.options|tojson }}
50+
);
51+
52+
{% if marker.popup %}
53+
{{ marker.get_name() }}.bindPopup({{ marker.popup.get_content()|tojson }});
54+
{% endif %}
55+
56+
oms.addMarker({{ marker.get_name() }});
57+
layerGroup.addLayer({{ marker.get_name() }});
58+
{% endfor %}
59+
} catch (error) {
60+
console.error('Error in OverlappingMarkerSpiderfier initialization:', error);
61+
}
62+
63+
return layerGroup;
64+
})();
65+
{% endmacro %}
66+
67+
"""
68+
)
69+
70+
default_js = [
71+
(
72+
"overlappingmarkerjs",
73+
"https://cdnjs.cloudflare.com/ajax/libs/OverlappingMarkerSpiderfier-Leaflet/0.2.6/oms.min.js",
74+
)
75+
]
76+
77+
def __init__(
78+
self,
79+
markers=None,
80+
name=None,
81+
overlay=True,
82+
control=True,
83+
show=True,
84+
options=None,
85+
**kwargs,
86+
):
87+
super().__init__(name=name, overlay=overlay, control=control, show=show)
88+
self._name = "OverlappingMarkerSpiderfier"
89+
90+
self.markers = markers or []
91+
92+
default_options = {
93+
"keepSpiderfied": True,
94+
"nearbyDistance": 20,
95+
"legWeight": 1.5,
96+
}
97+
if options:
98+
default_options.update(options)
99+
100+
self.options = parse_options(**default_options, **kwargs)
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""
2+
Test OverlappingMarkerSpiderfier
3+
--------------------------------
4+
"""
5+
6+
import numpy as np
7+
from jinja2 import Template
8+
9+
import folium
10+
from folium import plugins
11+
from folium.utilities import normalize
12+
13+
14+
def test_overlapping_marker_spiderfier():
15+
N = 10
16+
np.random.seed(seed=26082009)
17+
data = np.array(
18+
[
19+
np.random.uniform(low=45.0, high=45.1, size=N),
20+
np.random.uniform(low=3.0, high=3.1, size=N),
21+
]
22+
).T
23+
24+
m = folium.Map([45.05, 3.05], zoom_start=14)
25+
markers = [
26+
folium.Marker(location=loc, popup=f"Marker {i}") for i, loc in enumerate(data)
27+
]
28+
29+
for marker in markers:
30+
marker.add_to(m)
31+
32+
oms = plugins.OverlappingMarkerSpiderfier(
33+
markers=markers, options={"keepSpiderfied": True, "nearbyDistance": 20}
34+
).add_to(m)
35+
36+
tmpl_for_expected = Template(
37+
"""
38+
var {{this.get_name()}} = (function () {
39+
var layerGroup = L.layerGroup();
40+
try {
41+
var oms = new OverlappingMarkerSpiderfier(
42+
{{ this._parent.get_name() }},
43+
{{ this.options|tojson }}
44+
);
45+
46+
var popup = L.popup({
47+
offset: L.point(0, -30)
48+
});
49+
50+
oms.addListener('click', function(marker) {
51+
var content;
52+
if (marker.options && marker.options.options && marker.options.options.desc) {
53+
content = marker.options.options.desc;
54+
} else if (marker._popup && marker._popup._content) {
55+
content = marker._popup._content;
56+
} else {
57+
content = "";
58+
}
59+
60+
if (content) {
61+
popup.setContent(content);
62+
popup.setLatLng(marker.getLatLng());
63+
{{ this._parent.get_name() }}.openPopup(popup);
64+
}
65+
});
66+
67+
oms.addListener('spiderfy', function(markers) {
68+
{{ this._parent.get_name() }}.closePopup();
69+
});
70+
71+
{% for marker in this.markers %}
72+
var {{ marker.get_name() }} = L.marker(
73+
{{ marker.location|tojson }},
74+
{{ marker.options|tojson }}
75+
);
76+
77+
{% if marker.popup %}
78+
{{ marker.get_name() }}.bindPopup({{ marker.popup.get_content()|tojson }});
79+
{% endif %}
80+
81+
oms.addMarker({{ marker.get_name() }});
82+
layerGroup.addLayer({{ marker.get_name() }});
83+
{% endfor %}
84+
} catch (error) {
85+
console.error('Error in OverlappingMarkerSpiderfier initialization:', error);
86+
}
87+
88+
return layerGroup;
89+
})();
90+
"""
91+
)
92+
expected = normalize(tmpl_for_expected.render(this=oms))
93+
94+
out = normalize(m._parent.render())
95+
96+
assert (
97+
'<script src="https://cdnjs.cloudflare.com/ajax/libs/OverlappingMarkerSpiderfier-Leaflet/0.2.6/oms.min.js"></script>'
98+
in out
99+
)
100+
101+
assert expected in out
102+
103+
bounds = m.get_bounds()
104+
assert bounds is not None, "Map bounds should not be None"
105+
106+
min_lat, min_lon = data.min(axis=0)
107+
max_lat, max_lon = data.max(axis=0)
108+
109+
assert bounds[0][0] <= min_lat
110+
assert bounds[0][1] <= min_lon
111+
assert bounds[1][0] >= max_lat
112+
assert bounds[1][1] >= max_lon

0 commit comments

Comments
 (0)