From 384573a16c5832b623d06a62d087832552225fab Mon Sep 17 00:00:00 2001 From: mattijn Date: Sat, 13 Aug 2022 23:25:39 +0200 Subject: [PATCH 01/16] set up branch for multiple gdf input --- tests/test_extract.py | 12 ++++++++++++ topojson/core/extract.py | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/tests/test_extract.py b/tests/test_extract.py index b3444e6..d615564 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -476,3 +476,15 @@ def test_extract_read_geojson_from_json_dict(): topo = Extract(data).to_dict() assert len(topo["linestrings"]) == 287 + +def test_extract_read_multiple_gdf_object_name(): + world = geopandas.read_file(geopandas.datasets.get_path('naturalearth_lowres')) + world = world[['continent', 'geometry', 'pop_est']] + continents = world.dissolve(by='continent', aggfunc='sum') + + topo = Extract( + data=[world, continents], + options={'object_name': ['world', 'continents']} + ).to_dict() + + assert topo \ No newline at end of file diff --git a/topojson/core/extract.py b/topojson/core/extract.py index 54507a8..4a9787c 100644 --- a/topojson/core/extract.py +++ b/topojson/core/extract.py @@ -574,6 +574,11 @@ def _extract_list(self, geom): geom : list List instance """ + # check len of object_name + if type(self.options.object_name) == list and len(self.options.object_name) > 1: + self._is_multi = True + # and then ... to be continued + # convert list to indexed-dictionary data = dict(enumerate(geom)) From ebde6ef1a305f564f1e6b8f0f07b0ffdb3ce3e44 Mon Sep 17 00:00:00 2001 From: mattijn Date: Tue, 23 Aug 2022 11:22:54 +0200 Subject: [PATCH 02/16] speedups deprecationwarning --- topojson/core/join.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/topojson/core/join.py b/topojson/core/join.py index eee24d5..212fa5c 100644 --- a/topojson/core/join.py +++ b/topojson/core/join.py @@ -6,7 +6,6 @@ from shapely.wkb import loads from shapely.ops import shared_paths from shapely.ops import linemerge -from shapely import speedups from ..ops import select_unique_combs from ..ops import simplify from ..ops import quantize @@ -15,9 +14,6 @@ from ..utils import serialize_as_svg from .extract import Extract -if speedups.available: - speedups.enable() - class Join(Extract): """ From 18ed2cc07054d2e77f3686a6a8b09e995c3b7471 Mon Sep 17 00:00:00 2001 From: mattijn Date: Tue, 23 Aug 2022 11:27:20 +0200 Subject: [PATCH 03/16] use concat to stack; add __geom_name as geom-id --- tests/test_extract.py | 2 +- topojson/core/extract.py | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/test_extract.py b/tests/test_extract.py index d615564..57bccd4 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -487,4 +487,4 @@ def test_extract_read_multiple_gdf_object_name(): options={'object_name': ['world', 'continents']} ).to_dict() - assert topo \ No newline at end of file + assert len(topo['objects']) == len(world) + len(continents) \ No newline at end of file diff --git a/topojson/core/extract.py b/topojson/core/extract.py index fb5f6b6..22992e7 100644 --- a/topojson/core/extract.py +++ b/topojson/core/extract.py @@ -574,13 +574,18 @@ def _extract_list(self, geom): geom : list List instance """ - # check len of object_name + # check if list is list of features of list of objects. Use object_name if type(self.options.object_name) == list and len(self.options.object_name) > 1: - self._is_multi = True - # and then ... to be continued - - # convert list to indexed-dictionary - data = dict(enumerate(geom)) + if len(self.options.object_name) != len(geom): + raise LookupError('the number of data objects does not match the number of object_name') + for ix, df_geom in enumerate(geom): + df_geom['__geom_name'] = self.options.object_name[ix] + import pandas as pd + data = pd.concat(geom) + self._is_multi_geom = True + else: + # convert list to indexed-dictionary + data = dict(enumerate(geom)) # new data dictionary is created, throw the geometries back to main() self._is_single = False From 1d3264351e8456ab465322b4953d4c8c42b2134d Mon Sep 17 00:00:00 2001 From: mattijn Date: Tue, 23 Aug 2022 11:43:35 +0200 Subject: [PATCH 04/16] resolve back to objects --- tests/test_hashmap.py | 11 ++++++++++ topojson/core/hashmap.py | 47 ++++++++++++++++++++++++++++++---------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/tests/test_hashmap.py b/tests/test_hashmap.py index e3c1c5d..1e70d4c 100644 --- a/tests/test_hashmap.py +++ b/tests/test_hashmap.py @@ -295,3 +295,14 @@ def test_hashmap_serializing_holes(): arc = topo['objects']['data']['geometries'][0]['arcs'] assert arc == [[[0], [1], [2]], [[3]]] +def test_hashmap_read_multiple_gdf_object_name(): + world = geopandas.read_file(geopandas.datasets.get_path('naturalearth_lowres')) + world = world[['continent', 'geometry', 'pop_est']] + continents = world.dissolve(by='continent', aggfunc='sum') + + topo = Hashmap( + data=[world, continents], + options={'object_name': ['world', 'continents']} + ).to_dict() + + assert len(topo['objects']) == 2 \ No newline at end of file diff --git a/topojson/core/hashmap.py b/topojson/core/hashmap.py index 494a9c2..13e2bfb 100644 --- a/topojson/core/hashmap.py +++ b/topojson/core/hashmap.py @@ -92,22 +92,45 @@ def _hashmapper(self, data): # resolve bookkeeping of coordinates in objects, including delta-encoding list(self._resolve_objects(["arcs", "coordinates"], self._data["objects"])) - objects = {} - objects["geometries"] = [] - objects["type"] = "GeometryCollection" - for feature in data["objects"]: - feat = data["objects"][feature] - feat["id"] = feature + if self._is_multi_geom: + resolved_data_objects = {} + for object_name in self.options.object_name: + objects = {} + objects["geometries"] = [] + objects["type"] = "GeometryCollection" + for feature in data["objects"]: + feat = data["objects"][feature] + feat["id"] = feature - if "geometries" in feat and len(feat["geometries"]) == 1: - feat["type"] = feat["geometries"][0]["type"] + if feat['properties']['__geom_name'] == object_name: - self._resolve_arcs(feat) + if "geometries" in feat and len(feat["geometries"]) == 1: + feat["type"] = feat["geometries"][0]["type"] - objects["geometries"].append(feat) + self._resolve_arcs(feat) - data["objects"] = {} - data["objects"][self.options.object_name] = objects + objects["geometries"].append(feat) + resolved_data_objects[object_name] = objects + data["objects"] = {} + data["objects"] = resolved_data_objects + + else: + objects = {} + objects["geometries"] = [] + objects["type"] = "GeometryCollection" + for feature in data["objects"]: + feat = data["objects"][feature] + feat["id"] = feature + + if "geometries" in feat and len(feat["geometries"]) == 1: + feat["type"] = feat["geometries"][0]["type"] + + self._resolve_arcs(feat) + + objects["geometries"].append(feat) + + data["objects"] = {} + data["objects"][self.options.object_name] = objects # prepare to return object data = self._data From f77d78e81f9cad79e3faac59284144d561f443bb Mon Sep 17 00:00:00 2001 From: mattijn Date: Tue, 23 Aug 2022 11:51:09 +0200 Subject: [PATCH 05/16] next, test for topology --- tests/test_topology.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_topology.py b/tests/test_topology.py index 4fd6f10..edd1d4b 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -619,3 +619,15 @@ def test_topology_gdf_keep_index(): gdf_idx = topo.to_gdf().index.to_list() assert gdf_idx == [1, 2, 11, 12, 13] + +def test_topology_write_multiple_object_json_dict(): + world = geopandas.read_file(geopandas.datasets.get_path('naturalearth_lowres')) + world = world[['continent', 'geometry', 'pop_est']] + continents = world.dissolve(by='continent', aggfunc='sum') + + topo = topojson.Topology( + data=[world, continents], + options=['world', 'continents'] + ).to_dict() + + assert len(topo['objects']) == 2 \ No newline at end of file From 433ca95c51bf7c7ca0d17ad3475dd3de2bcee5c7 Mon Sep 17 00:00:00 2001 From: mattijn Date: Wed, 24 Aug 2022 12:53:56 +0200 Subject: [PATCH 06/16] make test valid, change parameter name --- tests/test_topology.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_topology.py b/tests/test_topology.py index edd1d4b..0c814a7 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -627,7 +627,8 @@ def test_topology_write_multiple_object_json_dict(): topo = topojson.Topology( data=[world, continents], - options=['world', 'continents'] - ).to_dict() + object_name=['world', 'continents'] + ) + topo_dict = topo.to_dict() - assert len(topo['objects']) == 2 \ No newline at end of file + assert len(topo_dict['objects']) == 2 \ No newline at end of file From a2d35ba3731dd4dc94ffb023221cc601dbe36973 Mon Sep 17 00:00:00 2001 From: mattijn Date: Wed, 24 Aug 2022 12:55:14 +0200 Subject: [PATCH 07/16] force object_name to be list type --- topojson/utils.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/topojson/utils.py b/topojson/utils.py index 23d941a..fc068eb 100644 --- a/topojson/utils.py +++ b/topojson/utils.py @@ -84,9 +84,12 @@ def __init__( self.winding_order = None if "object_name" in arguments: - self.object_name = arguments["object_name"] + if type(arguments["object_name"]) is not list: + self.object_name = [arguments["object_name"]] + else: + self.object_name = arguments["object_name"] else: - self.object_name = "data" + self.object_name = ["data"] def __repr__(self): return "TopoOptions(\n {}\n)".format(pprint.pformat(self.__dict__)) @@ -396,12 +399,11 @@ def serialize_as_svg(topo_object, separate=False, include_junctions=False): from IPython.display import SVG, display from shapely import geometry - keys = topo_object.keys() - if "arcs" in keys: + if "arcs" in topo_object: arcs = topo_object["arcs"] if arcs: # dequantize if quantization is applied - if "transform" in keys: + if "transform" in topo_object: np_arcs = np_array_from_arcs(arcs) @@ -492,7 +494,7 @@ def serialize_as_geojson( # prepare arcs from topology object arcs = topo_object["arcs"] transform = None - if "transform" in topo_object.keys(): + if "transform" in topo_object: transform = topo_object["transform"] scale = transform["scale"] translate = transform["translate"] @@ -510,8 +512,8 @@ def serialize_as_geojson( np_arcs = np.around(np_arcs, decimals=decimals) # select object member from topology object - if objectname not in topo_object["objects"].keys(): - raise SystemExit(f"'{objectname}' is not an object name in your topojson file") + if objectname not in topo_object["objects"]: + raise LookupError(f"'{objectname}' is not an object name in your topojson file") features = topo_object["objects"][objectname]["geometries"] # prepare geojson featurecollection From a89e9bd32182ae4210b12203e0501308caa2a6a9 Mon Sep 17 00:00:00 2001 From: mattijn Date: Wed, 24 Aug 2022 12:59:07 +0200 Subject: [PATCH 08/16] simplify checking of len object_name --- topojson/core/extract.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/topojson/core/extract.py b/topojson/core/extract.py index 22992e7..1c04032 100644 --- a/topojson/core/extract.py +++ b/topojson/core/extract.py @@ -60,6 +60,7 @@ def __init__(self, data, options={}): self._is_single = True self._invalid_geoms = 0 self._tried_geojson = False + self._is_multi_geom = False self.output = self._extractor(data) @@ -165,6 +166,7 @@ def _serialize_geom_type(self, geom): - geopandas.GeoSeries - dict of objects that provide a __geo_interface__ - list of objects that provide a __geo_interface__ + - list of geopandas.GeoDataFrames - object that provide a __geo_interface__ - TopoJSON dict - TopoJSON string @@ -574,8 +576,10 @@ def _extract_list(self, geom): geom : list List instance """ - # check if list is list of features of list of objects. Use object_name - if type(self.options.object_name) == list and len(self.options.object_name) > 1: + # check if there are multiple entries in the `object_name` in settings. + # currently only supports multiple GeoDataFrames as input entries + if len(self.options.object_name) > 1: + # list consist of objects if len(self.options.object_name) != len(geom): raise LookupError('the number of data objects does not match the number of object_name') for ix, df_geom in enumerate(geom): @@ -584,6 +588,7 @@ def _extract_list(self, geom): data = pd.concat(geom) self._is_multi_geom = True else: + # list consist of features # convert list to indexed-dictionary data = dict(enumerate(geom)) From f670d3ff80f09ea6eb14be48b913aa5c35d8d8d9 Mon Sep 17 00:00:00 2001 From: mattijn Date: Wed, 24 Aug 2022 13:01:12 +0200 Subject: [PATCH 09/16] simplify resolving data objects --- topojson/core/hashmap.py | 48 ++++++++++++++-------------------------- 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/topojson/core/hashmap.py b/topojson/core/hashmap.py index 13e2bfb..8030804 100644 --- a/topojson/core/hashmap.py +++ b/topojson/core/hashmap.py @@ -92,45 +92,31 @@ def _hashmapper(self, data): # resolve bookkeeping of coordinates in objects, including delta-encoding list(self._resolve_objects(["arcs", "coordinates"], self._data["objects"])) - if self._is_multi_geom: - resolved_data_objects = {} - for object_name in self.options.object_name: - objects = {} - objects["geometries"] = [] - objects["type"] = "GeometryCollection" - for feature in data["objects"]: - feat = data["objects"][feature] - feat["id"] = feature - - if feat['properties']['__geom_name'] == object_name: - - if "geometries" in feat and len(feat["geometries"]) == 1: - feat["type"] = feat["geometries"][0]["type"] - - self._resolve_arcs(feat) - - objects["geometries"].append(feat) - resolved_data_objects[object_name] = objects - data["objects"] = {} - data["objects"] = resolved_data_objects - - else: + resolved_data_objects = {} + for object_name in self.options.object_name: objects = {} objects["geometries"] = [] objects["type"] = "GeometryCollection" for feature in data["objects"]: feat = data["objects"][feature] - feat["id"] = feature - - if "geometries" in feat and len(feat["geometries"]) == 1: - feat["type"] = feat["geometries"][0]["type"] + feat["id"] = feature + if not self._is_multi_geom: + do_resolve = True + elif feat['properties']['__geom_name'] == object_name: + do_resolve = True + else: + do_resolve = False - self._resolve_arcs(feat) + if do_resolve: + if "geometries" in feat and len(feat["geometries"]) == 1: + feat["type"] = feat["geometries"][0]["type"] - objects["geometries"].append(feat) + self._resolve_arcs(feat) - data["objects"] = {} - data["objects"][self.options.object_name] = objects + objects["geometries"].append(feat) + resolved_data_objects[object_name] = objects + data["objects"] = {} + data["objects"] = resolved_data_objects # prepare to return object data = self._data From 0b101727425a932aef1a6a4dfa5bf19e03f7b998 Mon Sep 17 00:00:00 2001 From: mattijn Date: Wed, 24 Aug 2022 15:27:59 +0200 Subject: [PATCH 10/16] resolve object name in various output types --- topojson/core/topology.py | 91 ++++++++++++++++++++++++++------------- 1 file changed, 61 insertions(+), 30 deletions(-) diff --git a/topojson/core/topology.py b/topojson/core/topology.py index f3cf8fc..b8c48db 100644 --- a/topojson/core/topology.py +++ b/topojson/core/topology.py @@ -28,7 +28,10 @@ class Topology(Hashmap): Parameters ---------- data : _any_ geometric type - Geometric data that should be converted into TopoJSON + Geometric data that should be converted into TopoJSON. + It is possible to provide a list of multiple geopandas.GeoDataFrames as + separate objects. In this case it is required to provide an equal length list of + the names of the objects for parameter `object_name`. topology : boolean Specify if the topology should be computed for deriving the TopoJSON. Default is `True`. @@ -88,10 +91,13 @@ class Topology(Hashmap): clockwise for interior rings. Or `CCW_CW` for counter-clockwise for outer rings and clockwise for interior rings. Default is `CW_CCW` for TopoJSON. - object_name : str + object_name : Union[str, list[str]] Name to use as key for the objects in the topojson file. This name is used for writing and reading topojson file formats. - Default is `data`. + It is possible to define multiple objects within the topojson file. In this + case it is required to provide a list of the referenced `object_name` in + combination with an equal length list of `data` objects. + Default is a single object named `data`. """ def __init__( @@ -134,7 +140,7 @@ def __repr__(self): @property def __geo_interface__(self): topo_object = copy.deepcopy(self.output) - objectname = self.options.object_name + objectname = self._resolve_object_name(0) return serialize_as_geojson(topo_object, validate=False, objectname=objectname) def to_dict(self, options=False): @@ -211,6 +217,7 @@ def to_geojson( validate=False, winding_order="CCW_CW", decimals=None, + object_name=0 ): """ Convert the Topology to a GeoJSON object. Remember that this will destroy the @@ -245,10 +252,14 @@ def to_geojson( decimals : int or None Evenly round the coordinates to the given number of decimals. Default is None, which means no rounding is applied. + object_name : str, int + The name or the index of the object within the Topology to display. + Default is index 0. """ topo_object = copy.deepcopy(self.output) topo_object = self._resolve_coords(topo_object) - objectname = self.options.object_name + objectname = self._resolve_object_name(object_name) + fc = serialize_as_geojson( topo_object, validate=validate, @@ -260,7 +271,7 @@ def to_geojson( fc, fp, pretty=pretty, indent=indent, maxlinelength=maxlinelength ) - def to_gdf(self, crs=None, validate=False, winding_order="CCW_CW"): + def to_gdf(self, crs=None, validate=False, winding_order="CCW_CW", object_name=0): """ Convert the Topology to a GeoDataFrame. Remember that this will destroy the computed Topology. @@ -283,12 +294,15 @@ def to_gdf(self, crs=None, validate=False, winding_order="CCW_CW"): clockwise for interior rings. Or `CCW_CW` for counter-clockwise for outer rings and clockwise for interior rings. Default is `CCW_CW` for GeoJSON. + object_name : str, int + Name or index of the object. + Default is index `0` to select the first object. """ from ..utils import serialize_as_geodataframe topo_object = copy.deepcopy(self.output) topo_object = self._resolve_coords(topo_object) - objectname = self.options.object_name + objectname = self._resolve_object_name(object_name) fc = serialize_as_geojson( topo_object, validate=validate, objectname=objectname, order=winding_order ) @@ -297,7 +311,7 @@ def to_gdf(self, crs=None, validate=False, winding_order="CCW_CW"): crs = self._defined_crs_source return serialize_as_geodataframe(fc, crs=crs) - def to_alt(self, color=None, tooltip=True, projection="identity"): + def to_alt(self, color=None, tooltip=True, projection="identity", object_name=0): """ Display as Altair visualization. @@ -315,14 +329,14 @@ def to_alt(self, color=None, tooltip=True, projection="identity"): projection : str Defines the projection of the visualization. Defaults to a non-geographic, Cartesian projection (known by Altair as `identity`). - # objectname : str - # The name of the object within the Topology to display. - # Default is `data`. + object_name : str, int + The name or the index of the object within the Topology to display. + Default is index 0. """ from ..utils import serialize_as_altair topo_object = self.to_json() - objectname = self.options.object_name + objectname = self._resolve_object_name(object_name) return serialize_as_altair(topo_object, color, tooltip, projection, objectname) @@ -533,30 +547,47 @@ def toposimplify( return result def _resolve_coords(self, data): - objectname = self.options.object_name - if objectname not in data["objects"].keys(): - raise SystemExit( - f"'{objectname}' is not an object name in your topojson file" - ) - geoms = data["objects"][objectname]["geometries"] - for idx, feat in enumerate(geoms): - if feat["type"] in ["Point", "MultiPoint"]: - lofl = feat["coordinates"] - repeat = 1 if feat["type"] == "Point" else 2 + for objectname in self.options.object_name: + + if objectname not in data["objects"]: + raise SystemExit( + f"'{objectname}' is not an object name in your topojson file" + ) + geoms = data["objects"][objectname]["geometries"] + for idx, feat in enumerate(geoms): + if feat["type"] in ["Point", "MultiPoint"]: + + lofl = feat["coordinates"] + repeat = 1 if feat["type"] == "Point" else 2 - for _ in range(repeat): - lofl = list(itertools.chain(*lofl)) + for _ in range(repeat): + lofl = list(itertools.chain(*lofl)) - for idx, val in enumerate(lofl): - coord = data["coordinates"][val][0] - lofl[idx] = np.asarray(coord).tolist() + for idx, val in enumerate(lofl): + coord = data["coordinates"][val][0] + lofl[idx] = np.asarray(coord).tolist() - feat["coordinates"] = lofl[0] if len(lofl) == 1 else lofl - feat.pop("reset_coords", None) - data.pop("coordinates", None) + feat["coordinates"] = lofl[0] if len(lofl) == 1 else lofl + feat.pop("reset_coords", None) + data.pop("coordinates", None) return data + def _resolve_object_name(self, object_name): + # check if object_name as str or index is within self.options.object_name + if type(object_name) is int: + ix = object_name + if ix < len(self.options.object_name): + objectname = self.options.object_name[ix] + else: + raise IndexError(f'Cannot use object_name: "{object_name}" as index in objects: {self.options.object_name}. List index out of range') + else: + if object_name in self.options.object_name: + objectname = object_name + else: + raise LookupError(f'object_name: "{object_name}" not in objects: {self.options.object_name}') + return objectname + def _topo(self, data): self.output["arcs"] = data["linestrings"] del data["linestrings"] From 6749045956d5786d896ba85e19bdbf6d5e24d352 Mon Sep 17 00:00:00 2001 From: mattijn Date: Wed, 24 Aug 2022 23:38:59 +0200 Subject: [PATCH 11/16] no pd.concat; create list of features --- topojson/core/extract.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/topojson/core/extract.py b/topojson/core/extract.py index 1c04032..ba75939 100644 --- a/topojson/core/extract.py +++ b/topojson/core/extract.py @@ -61,6 +61,7 @@ def __init__(self, data, options={}): self._invalid_geoms = 0 self._tried_geojson = False self._is_multi_geom = False + self._geom_offset = 0 self.output = self._extractor(data) @@ -582,10 +583,18 @@ def _extract_list(self, geom): # list consist of objects if len(self.options.object_name) != len(geom): raise LookupError('the number of data objects does not match the number of object_name') - for ix, df_geom in enumerate(geom): - df_geom['__geom_name'] = self.options.object_name[ix] - import pandas as pd - data = pd.concat(geom) + geom_offset = np.cumsum([len(gdf) for gdf in geom]).tolist() + geom_offset.pop() + geom_offset.insert(0, 0) + self._geom_offset = geom_offset + for ix, gdf in enumerate(geom): + start = geom_offset[ix] + gdf['__geom_name'] = self.options.object_name[ix] + geom[ix] = dict(enumerate(gdf.to_dict(orient="records"), start)) + + for ix in range(1, len(geom)): + geom[0].update(geom.pop(ix)) + data = geom[0] self._is_multi_geom = True else: # list consist of features From 26504a64eacd57115bc7b3fce2632cb2cac1c6ba Mon Sep 17 00:00:00 2001 From: mattijn Date: Wed, 24 Aug 2022 23:39:27 +0200 Subject: [PATCH 12/16] track offset for resolving index --- topojson/core/hashmap.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/topojson/core/hashmap.py b/topojson/core/hashmap.py index 8030804..815a5de 100644 --- a/topojson/core/hashmap.py +++ b/topojson/core/hashmap.py @@ -93,17 +93,22 @@ def _hashmapper(self, data): list(self._resolve_objects(["arcs", "coordinates"], self._data["objects"])) resolved_data_objects = {} - for object_name in self.options.object_name: + for object_ix, object_name in enumerate(self.options.object_name): objects = {} objects["geometries"] = [] objects["type"] = "GeometryCollection" for feature in data["objects"]: feat = data["objects"][feature] - feat["id"] = feature if not self._is_multi_geom: do_resolve = True - elif feat['properties']['__geom_name'] == object_name: + feat["id"] = feature + elif ( + "__geom_name" in feat["properties"] + and feat["properties"]["__geom_name"] == object_name + ): do_resolve = True + feat["id"] = feature - self._geom_offset[object_ix] + del feat["properties"]["__geom_name"] else: do_resolve = False From c19714127170b606346abc238860762f470ea4c6 Mon Sep 17 00:00:00 2001 From: mattijn Date: Wed, 24 Aug 2022 23:42:31 +0200 Subject: [PATCH 13/16] blackify repo --- tests/test_extract.py | 12 ++++++------ tests/test_hashmap.py | 41 +++++++++++++++++++++------------------ tests/test_topology.py | 13 +++++++------ topojson/core/extract.py | 6 ++++-- topojson/core/topology.py | 14 ++++++++----- topojson/ops.py | 9 +++++---- topojson/utils.py | 9 ++++----- 7 files changed, 57 insertions(+), 47 deletions(-) diff --git a/tests/test_extract.py b/tests/test_extract.py index 57bccd4..0b9eb44 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -477,14 +477,14 @@ def test_extract_read_geojson_from_json_dict(): assert len(topo["linestrings"]) == 287 + def test_extract_read_multiple_gdf_object_name(): - world = geopandas.read_file(geopandas.datasets.get_path('naturalearth_lowres')) - world = world[['continent', 'geometry', 'pop_est']] - continents = world.dissolve(by='continent', aggfunc='sum') + world = geopandas.read_file(geopandas.datasets.get_path("naturalearth_lowres")) + world = world[["continent", "geometry", "pop_est"]] + continents = world.dissolve(by="continent", aggfunc="sum") topo = Extract( - data=[world, continents], - options={'object_name': ['world', 'continents']} + data=[world, continents], options={"object_name": ["world", "continents"]} ).to_dict() - assert len(topo['objects']) == len(world) + len(continents) \ No newline at end of file + assert len(topo["objects"]) == len(world) + len(continents) diff --git a/tests/test_hashmap.py b/tests/test_hashmap.py index 1e70d4c..6250024 100644 --- a/tests/test_hashmap.py +++ b/tests/test_hashmap.py @@ -30,8 +30,8 @@ def test_hashmap_geomcol_multipolygon_polygon(): topo = Hashmap(data).to_dict() assert topo["objects"]["data"]["geometries"][0]["geometries"][0]["arcs"] == [ - [[4, 0], [1]], - [[2]] + [[4, 0], [1]], + [[2]], ] @@ -276,33 +276,36 @@ def test_hashmap_fiona_gpkg_to_dict(): assert len(topo["linestrings"]) == 4 + # issue #148 and issue #167 def test_hashmap_serializing_holes(): - mp = geometry.shape({ - "type": "MultiPolygon", - "coordinates": [ - [ - [[0, 0], [20, 0], [10, 20], [0, 0]], # CCW - [[8, 2], [12, 12], [17, 2], [8, 2]], # CW - [[3, 2], [5, 6], [7, 2], [3, 2]], # CW + mp = geometry.shape( + { + "type": "MultiPolygon", + "coordinates": [ + [ + [[0, 0], [20, 0], [10, 20], [0, 0]], # CCW + [[8, 2], [12, 12], [17, 2], [8, 2]], # CW + [[3, 2], [5, 6], [7, 2], [3, 2]], # CW + ], + [[[10, 3], [15, 3], [12, 9], [10, 3]]], # CCW ], - [[[10, 3], [15, 3], [12, 9], [10, 3]]], # CCW - ] - }) + } + ) topo = Hashmap(mp) topo = topo.to_dict() - arc = topo['objects']['data']['geometries'][0]['arcs'] + arc = topo["objects"]["data"]["geometries"][0]["arcs"] assert arc == [[[0], [1], [2]], [[3]]] + def test_hashmap_read_multiple_gdf_object_name(): - world = geopandas.read_file(geopandas.datasets.get_path('naturalearth_lowres')) - world = world[['continent', 'geometry', 'pop_est']] - continents = world.dissolve(by='continent', aggfunc='sum') + world = geopandas.read_file(geopandas.datasets.get_path("naturalearth_lowres")) + world = world[["continent", "geometry", "pop_est"]] + continents = world.dissolve(by="continent", aggfunc="sum") topo = Hashmap( - data=[world, continents], - options={'object_name': ['world', 'continents']} + data=[world, continents], options={"object_name": ["world", "continents"]} ).to_dict() - assert len(topo['objects']) == 2 \ No newline at end of file + assert len(topo["objects"]) == 2 diff --git a/tests/test_topology.py b/tests/test_topology.py index 0c814a7..bf84699 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -608,6 +608,7 @@ def test_topology_topoquantize(): assert len(topo["arcs"]) == 149 + # test for https://github.com/mattijn/topojson/issues/164 def test_topology_gdf_keep_index(): gdf = ( @@ -620,15 +621,15 @@ def test_topology_gdf_keep_index(): assert gdf_idx == [1, 2, 11, 12, 13] + def test_topology_write_multiple_object_json_dict(): - world = geopandas.read_file(geopandas.datasets.get_path('naturalearth_lowres')) - world = world[['continent', 'geometry', 'pop_est']] - continents = world.dissolve(by='continent', aggfunc='sum') + world = geopandas.read_file(geopandas.datasets.get_path("naturalearth_lowres")) + world = world[["continent", "geometry", "pop_est"]] + continents = world.dissolve(by="continent", aggfunc="sum") topo = topojson.Topology( - data=[world, continents], - object_name=['world', 'continents'] + data=[world, continents], object_name=["world", "continents"] ) topo_dict = topo.to_dict() - assert len(topo_dict['objects']) == 2 \ No newline at end of file + assert len(topo_dict["objects"]) == 2 diff --git a/topojson/core/extract.py b/topojson/core/extract.py index ba75939..a6572f5 100644 --- a/topojson/core/extract.py +++ b/topojson/core/extract.py @@ -582,14 +582,16 @@ def _extract_list(self, geom): if len(self.options.object_name) > 1: # list consist of objects if len(self.options.object_name) != len(geom): - raise LookupError('the number of data objects does not match the number of object_name') + raise LookupError( + "the number of data objects does not match the number of object_name" + ) geom_offset = np.cumsum([len(gdf) for gdf in geom]).tolist() geom_offset.pop() geom_offset.insert(0, 0) self._geom_offset = geom_offset for ix, gdf in enumerate(geom): start = geom_offset[ix] - gdf['__geom_name'] = self.options.object_name[ix] + gdf["__geom_name"] = self.options.object_name[ix] geom[ix] = dict(enumerate(gdf.to_dict(orient="records"), start)) for ix in range(1, len(geom)): diff --git a/topojson/core/topology.py b/topojson/core/topology.py index b8c48db..c9ca697 100644 --- a/topojson/core/topology.py +++ b/topojson/core/topology.py @@ -217,7 +217,7 @@ def to_geojson( validate=False, winding_order="CCW_CW", decimals=None, - object_name=0 + object_name=0, ): """ Convert the Topology to a GeoJSON object. Remember that this will destroy the @@ -307,7 +307,7 @@ def to_gdf(self, crs=None, validate=False, winding_order="CCW_CW", object_name=0 topo_object, validate=validate, objectname=objectname, order=winding_order ) - if crs is None and hasattr(self, '_defined_crs_source'): + if crs is None and hasattr(self, "_defined_crs_source"): crs = self._defined_crs_source return serialize_as_geodataframe(fc, crs=crs) @@ -496,7 +496,7 @@ def toposimplify( # dequantize if transform exist if transform is not None: power_estimate = len(str(int(np_arcs[:, 0].max()))) - quant_factor_estimate = 10**power_estimate + quant_factor_estimate = 10 ** power_estimate np_arcs = dequantize(np_arcs, scale, translate) # apply simplify @@ -580,12 +580,16 @@ def _resolve_object_name(self, object_name): if ix < len(self.options.object_name): objectname = self.options.object_name[ix] else: - raise IndexError(f'Cannot use object_name: "{object_name}" as index in objects: {self.options.object_name}. List index out of range') + raise IndexError( + f'Cannot use object_name: "{object_name}" as index in objects: {self.options.object_name}. List index out of range' + ) else: if object_name in self.options.object_name: objectname = object_name else: - raise LookupError(f'object_name: "{object_name}" not in objects: {self.options.object_name}') + raise LookupError( + f'object_name: "{object_name}" not in objects: {self.options.object_name}' + ) return objectname def _topo(self, data): diff --git a/topojson/ops.py b/topojson/ops.py index b99655a..5472b6d 100644 --- a/topojson/ops.py +++ b/topojson/ops.py @@ -45,6 +45,7 @@ def ignore_shapely2_warnings(): warnings.filterwarnings("ignore", category=shapely_warning) yield + else: @contextlib.contextmanager @@ -81,11 +82,11 @@ def asvoid(arr): def strtree_query_geoms(tree, arc): # get junctions that contain within bbox line - if hasattr(tree, 'geometries'): + if hasattr(tree, "geometries"): # shapely version >= 1.8.3 tree_index = tree.query(arc) tree_geom = tree.geometries.take(tree_index) - elif hasattr(tree, 'query_geoms'): + elif hasattr(tree, "query_geoms"): # 1.8.0 <= shapely version < 1.8.3 tree_geom = tree.query_geoms(arc) else: @@ -95,10 +96,10 @@ def strtree_query_geoms(tree, arc): def strtree_query_index(tree, arc): - if hasattr(tree, 'geometries'): + if hasattr(tree, "geometries"): # shapely version >= 1.8.3 tree_index = tree.query(arc) - elif hasattr(tree, 'query_items'): + elif hasattr(tree, "query_items"): # 1.8.0 <= shapely version < 1.8.3 tree_index = tree.query_items(arc) else: diff --git a/topojson/utils.py b/topojson/utils.py index fc068eb..09999b8 100644 --- a/topojson/utils.py +++ b/topojson/utils.py @@ -389,9 +389,8 @@ def serialize_as_geodataframe(fc, crs=None): from pandas import json_normalize features = fc["features"] - return ( - GeoDataFrame.from_features(features=features, crs=crs) - .set_axis(json_normalize(features)["id"].values) + return GeoDataFrame.from_features(features=features, crs=crs).set_axis( + json_normalize(features)["id"].values ) @@ -521,8 +520,8 @@ def serialize_as_geojson( # fill the featurecollection with geometry object members for index, feature in enumerate(features): - f = {"id": feature.get('id', index), "type": "Feature"} - f["properties"] = feature.get('properties', {}) + f = {"id": feature.get("id", index), "type": "Feature"} + f["properties"] = feature.get("properties", {}) # the transform is only used in cases of points or multipoints geom_map = geometry(feature, np_arcs, transform) From 7ab8be724dfc5f64930245c34e43f44c3f2131c8 Mon Sep 17 00:00:00 2001 From: mattijn Date: Thu, 25 Aug 2022 00:25:40 +0200 Subject: [PATCH 14/16] not modify source gdf --- topojson/core/extract.py | 1 + 1 file changed, 1 insertion(+) diff --git a/topojson/core/extract.py b/topojson/core/extract.py index a6572f5..f53d478 100644 --- a/topojson/core/extract.py +++ b/topojson/core/extract.py @@ -590,6 +590,7 @@ def _extract_list(self, geom): geom_offset.insert(0, 0) self._geom_offset = geom_offset for ix, gdf in enumerate(geom): + gdf = gdf.copy() start = geom_offset[ix] gdf["__geom_name"] = self.options.object_name[ix] geom[ix] = dict(enumerate(gdf.to_dict(orient="records"), start)) From 0f4eb057d9f1667e7a58037660e143b2f612a358 Mon Sep 17 00:00:00 2001 From: mattijn Date: Fri, 26 Aug 2022 22:01:33 +0200 Subject: [PATCH 15/16] update docs --- docs/example/input-types.md | 63 +++++++++++++++++++++++++++++++ docs/example/output-types.md | 7 ++-- docs/images/multiple_objects.png | Bin 0 -> 69744 bytes 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 docs/images/multiple_objects.png diff --git a/docs/example/input-types.md b/docs/example/input-types.md index f7b873c..2630aba 100644 --- a/docs/example/input-types.md +++ b/docs/example/input-types.md @@ -366,6 +366,69 @@ tp.Topology(dict_in, prequantize=False).to_json() +* * * + +## `list` of GeoDataFrames +From the package `geopandas` (not a hard dependency). + +
+
+Example 🔧 +{: .label .label-blue-000 } +
+
+ +```python +import topojson as tp +import geopandas gpd + +gdf_1 = gpd.GeoDataFrame({ + "uniq_name": ["abc", "def"], + "shrd_name": ["rect", "rect"], + "geometry": [ + geometry.Polygon([[1, 1], [2, 1], [2, 2], [1, 2], [1, 1]]), + geometry.Polygon([[0, 1], [1, 1], [1, 2], [0, 2], [0, 1]]) + ] +}) +gdf_2 = gdf_1.dissolve(by='shrd_name', as_index=False) + +topo = tp.Topology(data=[gdf_1, gdf_2], object_name=['geom_1', 'geom_2'], prequantize=False) +topo.to_dict() + +``` + +```python +{'type': 'Topology', + 'objects': {'geom_1': {'geometries': [{'properties': {'uniq_name': 'abc', + 'shrd_name': 'rect'}, + 'type': 'Polygon', + 'arcs': [[-1, 2]], + 'id': 0}, + {'properties': {'uniq_name': 'def', 'shrd_name': 'rect'}, + 'type': 'Polygon', + 'arcs': [[1, 0, 3]], + 'id': 1}], + 'type': 'GeometryCollection'}, + 'geom_2': {'geometries': [{'properties': {'shrd_name': 'rect', + 'uniq_name': 'abc'}, + 'type': 'Polygon', + 'arcs': [[1, 2, 3]], + 'id': 0}], + 'type': 'GeometryCollection'}}, + 'bbox': (0.0, 1.0, 2.0, 2.0), + 'arcs': [[[1.0, 2.0], [1.0, 1.0]], + [[0.0, 1.0], [0.0, 2.0], [1.0, 2.0]], + [[1.0, 2.0], [2.0, 2.0], [2.0, 1.0], [1.0, 1.0]], + [[1.0, 1.0], [0.0, 1.0]]]} +``` +```python +topo.to_gdf(object_name='geom_2').plot(column='shrd_name') +topo.to_gdf(object_name='geom_1').plot(column='uniq_name') +``` + + +
+