diff --git a/docs/user_guide/plugins.rst b/docs/user_guide/plugins.rst index e9e18324f..947a1ea35 100644 --- a/docs/user_guide/plugins.rst +++ b/docs/user_guide/plugins.rst @@ -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 diff --git a/docs/user_guide/plugins/overlapping_marker_spiderfier.md b/docs/user_guide/plugins/overlapping_marker_spiderfier.md new file mode 100644 index 000000000..957f841fb --- /dev/null +++ b/docs/user_guide/plugins/overlapping_marker_spiderfier.md @@ -0,0 +1,107 @@ +# 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. + +## Using with 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( + keep_spiderfied=True, # Markers remain spiderfied after clicking + nearby_distance=20, # Distance for clustering markers in pixel + circle_spiral_switchover=10, # Threshold for switching between circle and spiral + leg_weight=2.0 # Line thickness for spider legs + ) +oms.add_to(m) + +m +``` + +## Using with FeatureGroups + +```{code-cell} ipython3 +import folium +from folium.plugins import OverlappingMarkerSpiderfier + +# Create a map +m = folium.Map(location=[45.05, 3.05], zoom_start=13) + +# Create a FeatureGroup +feature_group = folium.FeatureGroup(name='Feature Group') + +# Add markers to the FeatureGroup +for i in range(10): + folium.Marker( + location=[45.05 + i * 0.0001, 3.05 + i * 0.0001], + popup=f"Feature Group Marker {i}" + ).add_to(feature_group) + +# Add the FeatureGroup to the map +feature_group.add_to(m) + +# Initialize OverlappingMarkerSpiderfier +oms = OverlappingMarkerSpiderfier() +oms.add_to(m) + +m +``` + +## Using with FeatureGroupSubGroups + +```{code-cell} ipython3 +import folium +from folium.plugins import OverlappingMarkerSpiderfier, FeatureGroupSubGroup + +# Create a map +m = folium.Map(location=[45.05, 3.05], zoom_start=13) + +# Create a main FeatureGroup +main_group = folium.FeatureGroup(name='Main Group') + +# Create sub-groups +sub_group1 = FeatureGroupSubGroup(main_group, name='Sub Group 1') +sub_group2 = FeatureGroupSubGroup(main_group, name='Sub Group 2') + +# Add markers to the first sub-group +for i in range(10): + folium.Marker( + location=[45.05 + i * 0.0001, 3.05 + i * 0.0001], + popup=f"Sub Group 1 Marker {i}" + ).add_to(sub_group1) + +# Add markers to the second sub-group +for i in range(10, 20): + folium.Marker( + location=[45.06 + (i - 10) * 0.0001, 3.06 + (i - 10) * 0.0001], + popup=f"Sub Group 2 Marker {i}" + ).add_to(sub_group2) + +# Add the main group to the map +main_group.add_to(m) + +# Add sub-groups to the map +sub_group1.add_to(m) +sub_group2.add_to(m) + +# Initialize OverlappingMarkerSpiderfier +oms = OverlappingMarkerSpiderfier() +oms.add_to(m) + +# Add the LayerControl plugin +folium.LayerControl().add_to(m) + +m +``` diff --git a/folium/plugins/__init__.py b/folium/plugins/__init__.py index ad857f48a..59e89b883 100644 --- a/folium/plugins/__init__.py +++ b/folium/plugins/__init__.py @@ -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 @@ -56,6 +57,7 @@ "MeasureControl", "MiniMap", "MousePosition", + "OverlappingMarkerSpiderfier", "PolygonFromEncoded", "PolyLineFromEncoded", "PolyLineTextPath", diff --git a/folium/plugins/overlapping_marker_spiderfier.py b/folium/plugins/overlapping_marker_spiderfier.py new file mode 100644 index 000000000..70fcea412 --- /dev/null +++ b/folium/plugins/overlapping_marker_spiderfier.py @@ -0,0 +1,104 @@ +from typing import Optional + +from jinja2 import Template + +from folium.elements import Element, JSCSSMixin, MacroElement +from folium.map import Marker +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 are automatically identified and managed by the plugin, so there is no need to add them separately. + Simply add the plugin to the map using `oms.add_to(map)`. + + Parameters + ---------- + keep_spiderfied : bool, default True + If true, markers stay spiderfied after clicking. + nearby_distance : int, default 20 + Pixels away from a marker that is considered overlapping. + leg_weight : float, default 1.5 + Weight of the spider legs. + circle_spiral_switchover : int, default 9 + Number of markers at which to switch from circle to spiral pattern. + + Example + ------- + >>> oms = OverlappingMarkerSpiderfier( + ... keep_spiderfied=True, nearby_distance=30, leg_weight=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(); + }); + + {%- for marker in this.markers %} + oms.addMarker({{ marker.get_name() }}); + {%- endfor %} + } 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, + keep_spiderfied: bool = True, + nearby_distance: int = 20, + leg_weight: float = 1.5, + circle_spiral_switchover: int = 9, + **kwargs + ): + super().__init__() + self._name = "OverlappingMarkerSpiderfier" + self.options = parse_options( + keep_spiderfied=keep_spiderfied, + nearby_distance=nearby_distance, + leg_weight=leg_weight, + circle_spiral_switchover=circle_spiral_switchover, + **kwargs + ) + + def add_to( + self, parent: Element, name: Optional[str] = None, index: Optional[int] = None + ) -> Element: + self._parent = parent + self.markers = self._get_all_markers(parent) + super().add_to(parent, name=name, index=index) + + def _get_all_markers(self, element: Element) -> list: + markers = [] + for child in element._children.values(): + if isinstance(child, Marker): + markers.append(child) + elif hasattr(child, "_children"): + markers.extend(self._get_all_markers(child)) + return markers diff --git a/tests/plugins/test_overlapping_marker_spiderfier.py b/tests/plugins/test_overlapping_marker_spiderfier.py new file mode 100644 index 000000000..45004d538 --- /dev/null +++ b/tests/plugins/test_overlapping_marker_spiderfier.py @@ -0,0 +1,115 @@ +""" +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 ( + '' + 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( + keep_spiderfied=True, + nearby_distance=20, + ) + oms.add_to(m) + + assert ( + oms.get_name() in m._children + ), "OverlappingMarkerSpiderfier is not correctly added to the map."