Skip to content

Commit 9d807fc

Browse files
committed
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.
1 parent 2ca2be8 commit 9d807fc

2 files changed

Lines changed: 113 additions & 32 deletions

File tree

‎folium/features.py‎

Lines changed: 52 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -48,8 +48,6 @@
4848
get_obj_in_upper_tree,
4949
image_to_url,
5050
javascript_identifier_path_to_array_notation,
51-
none_max,
52-
none_min,
5351
remove_empty,
5452
validate_locations,
5553
)
@@ -1086,39 +1084,61 @@ def render(self, **kwargs):
10861084
super().render(**kwargs)
10871085

10881086
def get_bounds(self) -> TypeBoundsReturn:
1089-
"""
1090-
Computes the bounds of the object itself (not including it's children)
1091-
in the form [[lat_min, lon_min], [lat_max, lon_max]]
1092-
1093-
"""
1087+
"""Compute bounds of the selected TopoJSON object in latitude/longitude order."""
10941088
if not self.embed:
10951089
raise ValueError("Cannot compute bounds of non-embedded TopoJSON.")
10961090

1097-
xmin, xmax, ymin, ymax = None, None, None, None
1098-
1099-
for arc in self.data["arcs"]:
1100-
x, y = 0, 0
1101-
for dx, dy in arc:
1102-
x += dx
1103-
y += dy
1104-
xmin = none_min(x, xmin)
1105-
xmax = none_max(x, xmax)
1106-
ymin = none_min(y, ymin)
1107-
ymax = none_max(y, ymax)
1108-
return [
1109-
[
1110-
self.data["transform"]["translate"][1]
1111-
+ self.data["transform"]["scale"][1] * ymin, # noqa
1112-
self.data["transform"]["translate"][0]
1113-
+ self.data["transform"]["scale"][0] * xmin, # noqa
1114-
],
1115-
[
1116-
self.data["transform"]["translate"][1]
1117-
+ self.data["transform"]["scale"][1] * ymax, # noqa
1118-
self.data["transform"]["translate"][0]
1119-
+ self.data["transform"]["scale"][0] * xmax, # noqa
1120-
],
1121-
]
1091+
transform = self.data.get("transform")
1092+
scale = transform["scale"] if transform else (1, 1)
1093+
translate = transform["translate"] if transform else (0, 0)
1094+
xmin = xmax = ymin = ymax = None
1095+
1096+
def include_point(x, y):
1097+
nonlocal xmin, xmax, ymin, ymax
1098+
x = translate[0] + scale[0] * x
1099+
y = translate[1] + scale[1] * y
1100+
xmin = x if xmin is None else min(x, xmin)
1101+
xmax = x if xmax is None else max(x, xmax)
1102+
ymin = y if ymin is None else min(y, ymin)
1103+
ymax = y if ymax is None else max(y, ymax)
1104+
1105+
def iter_arc_indices(value):
1106+
if isinstance(value, int):
1107+
yield value if value >= 0 else ~value
1108+
else:
1109+
for item in value:
1110+
yield from iter_arc_indices(item)
1111+
1112+
def visit_coordinates(coordinates):
1113+
if not coordinates:
1114+
return
1115+
if isinstance(coordinates[0], (int, float)):
1116+
include_point(*coordinates[:2])
1117+
else:
1118+
for item in coordinates:
1119+
visit_coordinates(item)
1120+
1121+
def visit_geometry(geometry):
1122+
for child in geometry.get("geometries", []):
1123+
visit_geometry(child)
1124+
for index in iter_arc_indices(geometry.get("arcs", [])):
1125+
x = y = 0
1126+
for dx, dy in self.data["arcs"][index]:
1127+
if transform:
1128+
x += dx
1129+
y += dy
1130+
else:
1131+
x, y = dx, dy
1132+
include_point(x, y)
1133+
if "coordinates" in geometry:
1134+
visit_coordinates(geometry["coordinates"])
1135+
1136+
geometry = self.data
1137+
for key in self.object_path.split("."):
1138+
geometry = geometry[key]
1139+
visit_geometry(geometry)
1140+
1141+
return [[ymin, xmin], [ymax, xmax]]
11221142

11231143

11241144
class GeoJsonDetail(MacroElement):

‎tests/test_features.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,67 @@ def test_topojson_non_collection_geometry_detail(detail_type):
8686
assert "name" in map_.get_root().render()
8787

8888

89+
@pytest.fixture
90+
def topojson_bounds_data():
91+
return {
92+
"type": "Topology",
93+
"objects": {
94+
"near": {"type": "Polygon", "arcs": [[0]]},
95+
"far": {"type": "Polygon", "arcs": [[1]]},
96+
},
97+
"arcs": [
98+
[[0, 0], [2, 0], [0, 1], [-2, -1]],
99+
[[100, 60], [2, 0], [0, 1], [-2, -1]],
100+
],
101+
"transform": {"scale": [1, 1], "translate": [0, 0]},
102+
}
103+
104+
105+
def test_topojson_bounds_use_selected_arcs(topojson_bounds_data):
106+
near = folium.TopoJson(topojson_bounds_data, "objects.near")
107+
far = folium.TopoJson(topojson_bounds_data, "objects.far")
108+
assert near.get_bounds() == [[0, 0], [1, 2]]
109+
assert far.get_bounds() == [[60, 100], [61, 102]]
110+
111+
map_ = folium.Map()
112+
near.add_to(map_)
113+
assert map_.get_bounds() == [[0, 0], [1, 2]]
114+
115+
116+
def test_topojson_bounds_reversed_arc(topojson_bounds_data):
117+
topojson_bounds_data["objects"]["near"]["arcs"] = [[-2]]
118+
layer = folium.TopoJson(topojson_bounds_data, "objects.near")
119+
assert layer.get_bounds() == [[60, 100], [61, 102]]
120+
121+
122+
def test_topojson_bounds_mixed_geometry_collection(topojson_bounds_data):
123+
topojson_bounds_data["objects"]["near"] = {
124+
"type": "GeometryCollection",
125+
"geometries": [
126+
{"type": "Point", "coordinates": [4, 5]},
127+
{"type": "Polygon", "arcs": [[0]]},
128+
],
129+
}
130+
layer = folium.TopoJson(topojson_bounds_data, "objects.near")
131+
assert layer.get_bounds() == [[0, 0], [5, 4]]
132+
133+
134+
def test_topojson_bounds_negative_scale(topojson_bounds_data):
135+
topojson_bounds_data["transform"] = {"scale": [-2, -3], "translate": [100, 200]}
136+
layer = folium.TopoJson(topojson_bounds_data, "objects.near")
137+
assert layer.get_bounds() == [[197, 96], [200, 100]]
138+
139+
140+
def test_topojson_bounds_absolute_arcs_without_transform():
141+
data = {
142+
"type": "Topology",
143+
"objects": {"near": {"type": "LineString", "arcs": [0]}},
144+
"arcs": [[[12.0, 7.0], [14.0, 9.0], [10.0, 8.0]]],
145+
}
146+
layer = folium.TopoJson(data, "objects.near")
147+
assert layer.get_bounds() == [[7, 10], [9, 14]]
148+
149+
89150
@pytest.fixture
90151
def tmpl():
91152
yield ("""

0 commit comments

Comments
 (0)