From 9d807fcc2f6ecaf6de168f2e64de41ca6b66121c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Senanur=20=C3=87etin?= Date: Wed, 21 Oct 2026 01:25:05 +0300 Subject: [PATCH] fix: compute TopoJSON bounds from the selected object only Decode selected arcs (including reversed indices), geometry collections, point coordinates, and absolute coordinates without transforms. Handle negative transforms and add regression tests for map bounds. --- folium/features.py | 84 ++++++++++++++++++++++++++---------------- tests/test_features.py | 61 ++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 32 deletions(-) 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 ("""