Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature overlapping markers #2033

Merged
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