Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
29 changes: 29 additions & 0 deletions docs/user_guide/plugins/overlapping_marker_spiderfier.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# OverlappingMarkerSpiderfier

The `OverlappingMarkerSpiderfier` is a plugin for Folium that helps manage overlapping markers by "spiderfying" them when clicked, making it easier to select individual markers.

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

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

# Add markers to the map
for i in range(20):
folium.Marker(
location=[45.05 + i * 0.0001, 3.05 + i * 0.0001],
popup=f"Marker {i}"
).add_to(m)

# Add the OverlappingMarkerSpiderfier plugin
oms = OverlappingMarkerSpiderfier(options={
"keepSpiderfied": True, # Markers remain spiderfied after clicking
"nearbyDistance": 20, # Distance for clustering markers in pixel
"circleSpiralSwitchover": 10, # Threshold for switching between circle and spiral
"legWeight": 2.0 # Line thickness for spider legs
})
oms.add_to(m)

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
86 changes: 86 additions & 0 deletions folium/plugins/overlapping_marker_spiderfier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from jinja2 import Template

from folium.elements import JSCSSMixin, MacroElement
from folium.utilities import parse_options


class OverlappingMarkerSpiderfier(JSCSSMixin, MacroElement):
"""
A plugin that handles overlapping markers on a map by spreading them out in a spiral or circle pattern when clicked.

This plugin is useful when you have multiple markers in close proximity that would otherwise be difficult to interact with.
When a user clicks on a cluster of overlapping markers, they spread out in a 'spider' pattern, making each marker
individually accessible.

Markers must be added to the map **before** calling `oms.add_to(map)`.
The plugin identifies and manages all markers already present on the map.

Parameters
----------
options : dict, optional
The options to configure the spiderfier behavior:
- keepSpiderfied : bool, default True
If true, markers stay spiderfied after clicking
- nearbyDistance : int, default 20
Pixels away from a marker that is considered overlapping
- legWeight : float, default 1.5
Weight of the spider legs
- circleSpiralSwitchover : int, optional
Number of markers at which to switch from circle to spiral pattern

Example
-------
>>> oms = OverlappingMarkerSpiderfier(
... options={"keepSpiderfied": True, "nearbyDistance": 30, "legWeight": 2.0}
... )
>>> oms.add_to(map)
"""

_template = Template(
"""
{% macro script(this, kwargs) %}
(function () {
try {
var oms = new OverlappingMarkerSpiderfier(
{{ this._parent.get_name() }},
{{ this.options|tojson }}
);

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

{{ this._parent.get_name() }}.eachLayer(function(layer) {
if (
layer instanceof L.Marker
) {
oms.addMarker(layer);
}
});

} catch (error) {
console.error('Error initializing OverlappingMarkerSpiderfier:', error);
}
})();
{% endmacro %}
"""
)

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

def __init__(self, options=None, **kwargs):
super().__init__()
self._name = "OverlappingMarkerSpiderfier"
default_options = {
"keepSpiderfied": True,
"nearbyDistance": 20,
"legWeight": 1.5,
}
if options:
default_options.update(options)
self.options = parse_options(**default_options, **kwargs)
114 changes: 114 additions & 0 deletions tests/plugins/test_overlapping_marker_spiderfier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""
Test OverlappingMarkerSpiderfier
--------------------------------
"""

import numpy as np

from folium.folium import Map
from folium.map import Marker
from folium.plugins.overlapping_marker_spiderfier import OverlappingMarkerSpiderfier


def test_oms_js_inclusion():
"""
Test that the OverlappingMarkerSpiderfier JavaScript library is included in the map.
"""
m = Map([45.05, 3.05], zoom_start=14)
OverlappingMarkerSpiderfier().add_to(m)

rendered_map = m._parent.render()
assert (
'<script src="https://cdnjs.cloudflare.com/ajax/libs/OverlappingMarkerSpiderfier-Leaflet/0.2.6/oms.min.js"></script>'
in rendered_map
), "OverlappingMarkerSpiderfier JS file is missing in the rendered output."


def test_marker_addition():
"""
Test that markers are correctly added to the map.
"""
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 = Map([45.05, 3.05], zoom_start=14)
markers = [
Marker(
location=loc,
popup=f"Marker {i}",
)
for i, loc in enumerate(data)
]

for marker in markers:
marker.add_to(m)

assert (
len(m._children) == len(markers) + 1
), f"Expected {len(markers)} markers, found {len(m._children) - 1}."


def test_map_bounds():
"""
Test that the map bounds correctly encompass all added markers.
"""
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 = Map([45.05, 3.05], zoom_start=14)
markers = [
Marker(
location=loc,
popup=f"Marker {i}",
)
for i, loc in enumerate(data)
]

for marker in markers:
marker.add_to(m)

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
), "Map bounds do not correctly include the minimum latitude."
assert (
bounds[0][1] <= min_lon
), "Map bounds do not correctly include the minimum longitude."
assert (
bounds[1][0] >= max_lat
), "Map bounds do not correctly include the maximum latitude."
assert (
bounds[1][1] >= max_lon
), "Map bounds do not correctly include the maximum longitude."


def test_overlapping_marker_spiderfier_integration():
"""
Test that OverlappingMarkerSpiderfier integrates correctly with the map.
"""
m = Map([45.05, 3.05], zoom_start=14)
oms = OverlappingMarkerSpiderfier(
options={"keepSpiderfied": True, "nearbyDistance": 20}
)
oms.add_to(m)

assert (
oms.get_name() in m._children
), "OverlappingMarkerSpiderfier is not correctly added to the map."