diff --git a/folium/features.py b/folium/features.py index 587f5e053b..9d8c171e53 100644 --- a/folium/features.py +++ b/folium/features.py @@ -48,8 +48,6 @@ get_obj_in_upper_tree, image_to_url, javascript_identifier_path_to_array_notation, - none_max, - none_min, remove_empty, validate_locations, ) @@ -1086,39 +1084,61 @@ def render(self, **kwargs): super().render(**kwargs) def get_bounds(self) -> TypeBoundsReturn: - """ - Computes the bounds of the object itself (not including it's children) - in the form [[lat_min, lon_min], [lat_max, lon_max]] - - """ + """Compute bounds of the selected TopoJSON object in latitude/longitude order.""" if not self.embed: raise ValueError("Cannot compute bounds of non-embedded TopoJSON.") - xmin, xmax, ymin, ymax = None, None, None, None - - for arc in self.data["arcs"]: - x, y = 0, 0 - for dx, dy in arc: - x += dx - y += dy - xmin = none_min(x, xmin) - xmax = none_max(x, xmax) - ymin = none_min(y, ymin) - ymax = none_max(y, ymax) - return [ - [ - self.data["transform"]["translate"][1] - + self.data["transform"]["scale"][1] * ymin, # noqa - self.data["transform"]["translate"][0] - + self.data["transform"]["scale"][0] * xmin, # noqa - ], - [ - self.data["transform"]["translate"][1] - + self.data["transform"]["scale"][1] * ymax, # noqa - self.data["transform"]["translate"][0] - + self.data["transform"]["scale"][0] * xmax, # noqa - ], - ] + transform = self.data.get("transform") + scale = transform["scale"] if transform else (1, 1) + translate = transform["translate"] if transform else (0, 0) + xmin = xmax = ymin = ymax = None + + def include_point(x, y): + nonlocal xmin, xmax, ymin, ymax + x = translate[0] + scale[0] * x + y = translate[1] + scale[1] * y + xmin = x if xmin is None else min(x, xmin) + xmax = x if xmax is None else max(x, xmax) + ymin = y if ymin is None else min(y, ymin) + ymax = y if ymax is None else max(y, ymax) + + def iter_arc_indices(value): + if isinstance(value, int): + yield value if value >= 0 else ~value + else: + for item in value: + yield from iter_arc_indices(item) + + def visit_coordinates(coordinates): + if not coordinates: + return + if isinstance(coordinates[0], (int, float)): + include_point(*coordinates[:2]) + else: + for item in coordinates: + visit_coordinates(item) + + def visit_geometry(geometry): + for child in geometry.get("geometries", []): + visit_geometry(child) + for index in iter_arc_indices(geometry.get("arcs", [])): + x = y = 0 + for dx, dy in self.data["arcs"][index]: + if transform: + x += dx + y += dy + else: + x, y = dx, dy + include_point(x, y) + if "coordinates" in geometry: + visit_coordinates(geometry["coordinates"]) + + geometry = self.data + for key in self.object_path.split("."): + geometry = geometry[key] + visit_geometry(geometry) + + return [[ymin, xmin], [ymax, xmax]] class GeoJsonDetail(MacroElement): diff --git a/tests/test_features.py b/tests/test_features.py index e41e0e8337..8ced5097f8 100644 --- a/tests/test_features.py +++ b/tests/test_features.py @@ -86,6 +86,67 @@ def test_topojson_non_collection_geometry_detail(detail_type): assert "name" in map_.get_root().render() +@pytest.fixture +def topojson_bounds_data(): + return { + "type": "Topology", + "objects": { + "near": {"type": "Polygon", "arcs": [[0]]}, + "far": {"type": "Polygon", "arcs": [[1]]}, + }, + "arcs": [ + [[0, 0], [2, 0], [0, 1], [-2, -1]], + [[100, 60], [2, 0], [0, 1], [-2, -1]], + ], + "transform": {"scale": [1, 1], "translate": [0, 0]}, + } + + +def test_topojson_bounds_use_selected_arcs(topojson_bounds_data): + near = folium.TopoJson(topojson_bounds_data, "objects.near") + far = folium.TopoJson(topojson_bounds_data, "objects.far") + assert near.get_bounds() == [[0, 0], [1, 2]] + assert far.get_bounds() == [[60, 100], [61, 102]] + + map_ = folium.Map() + near.add_to(map_) + assert map_.get_bounds() == [[0, 0], [1, 2]] + + +def test_topojson_bounds_reversed_arc(topojson_bounds_data): + topojson_bounds_data["objects"]["near"]["arcs"] = [[-2]] + layer = folium.TopoJson(topojson_bounds_data, "objects.near") + assert layer.get_bounds() == [[60, 100], [61, 102]] + + +def test_topojson_bounds_mixed_geometry_collection(topojson_bounds_data): + topojson_bounds_data["objects"]["near"] = { + "type": "GeometryCollection", + "geometries": [ + {"type": "Point", "coordinates": [4, 5]}, + {"type": "Polygon", "arcs": [[0]]}, + ], + } + layer = folium.TopoJson(topojson_bounds_data, "objects.near") + assert layer.get_bounds() == [[0, 0], [5, 4]] + + +def test_topojson_bounds_negative_scale(topojson_bounds_data): + topojson_bounds_data["transform"] = {"scale": [-2, -3], "translate": [100, 200]} + layer = folium.TopoJson(topojson_bounds_data, "objects.near") + assert layer.get_bounds() == [[197, 96], [200, 100]] + + +def test_topojson_bounds_absolute_arcs_without_transform(): + data = { + "type": "Topology", + "objects": {"near": {"type": "LineString", "arcs": [0]}}, + "arcs": [[[12.0, 7.0], [14.0, 9.0], [10.0, 8.0]]], + } + layer = folium.TopoJson(data, "objects.near") + assert layer.get_bounds() == [[7, 10], [9, 14]] + + @pytest.fixture def tmpl(): yield ("""