Repository: mattijn/topojson

Issue:
Converting a quantized topojson string to geojson fails to dequantize the coordinates
First, thanks a lot for your work on this library, I love how lightweight and easy to use it is! 

I believe I have encountered an issue however. It appears to be me that much of the library and docs is geared towards converting geojson to topojson, e.g. `Topology(geojsondata)`. However, my use-case is more the other way, if I have data stored as a topojson string and want to convert it to geojson. After some digging, I ended up using the `utils.geometry` function, which works great for non-quantized topojsons. 

https://github.com/mattijn/topojson/blob/c011d668d4f176b018109c46c6e5affc1b4cf468/topojson/utils.py#L120

My issue however is when using `utils.geometry` for topojsons with quantized coordinates, the output geojson remains in quantized form even though I supplied the `transform` arg. I looked into the source code and it appears that while the dequantize function is applied for Point and MultiPoint types, it is not applied for the geometry types in the else-statement as well as the GeometryCollection case. I could perhaps submit a PR for this, but wanted to just confirm that I've understood it right? 

As an aside, I think it would be useful if the use case of topojson-to-geojson was specifically listed in the docs (or the functionality made more prominent, e.g. by loading a Topology object from a topojson string directly), as I think this would be a pretty common use-case. 

Discussion:
mattijn: Serializing to altair is included as well as more tests. Made sure all other tests are still passing

Patch:
diff --git a/docs/example/input-types.md b/docs/example/input-types.md
index 7f7e2fd..feb6c32 100644
--- a/docs/example/input-types.md
+++ b/docs/example/input-types.md
@@ -309,4 +309,30 @@ tp.Topology(dict_in, prequantize=False).to_json()
 }
 ```
 </div>
+</div>
+
+* * * 
+
+## TopoJSON file loaded as json-dict
+A TopoJSON file can be postprocessed.
+
+<div class="code-example mx-1 bg-example">
+<div class="example-label" markdown="1">
+Example 🔧
+{: .label .label-blue-000 }
+</div>
+<div class="example-text" markdown="1">
+
+```python
+import topojson as tp
+import json
+
+with open("tests/files_topojson/naturalearth_lowres_africa.topojson", 'r') as f:
+    data = json.load(f)
+# parse topojson file using `objects_name`
+topo = topojson.Topology(data, objects_name="data")
+topo.toposimplify(4).to_svg()
+```
+<img src="../images/africa_toposimp.svg">
+</div>
 </div>
\ No newline at end of file
diff --git a/topojson/core/extract.py b/topojson/core/extract.py
index 4519704..ae78daa 100644
--- a/topojson/core/extract.py
+++ b/topojson/core/extract.py
@@ -59,18 +59,6 @@ class Extract(object):
         self._invalid_geoms = 0
         self._tried_geojson = False
 
-        # if instance(data) == "Collection":  # fiona.Collection
-        #     copydata = data
-        # else:
-        #     # FIXME: try except is not necessary once the following issue is fixed:
-        #     # https://github.com/geopandas/geopandas/issues/1070
-        #     try:
-        #         copydata = copy.deepcopy(data)
-        #     except TypeError:
-        #         if hasattr(data, "copy"):
-        #             copydata = data.copy()
-        #         else:
-        #             copydata = data
         self.output = self._extractor(data)
 
     def __repr__(self):
@@ -176,6 +164,7 @@ class Extract(object):
         - dict of objects that provide a __geo_interface__
         - list of objects that provide a __geo_interface__
         - object that provide a __geo_interface__
+        - TopoJSON dict
         - TopoJSON string
         - GeoJSON string
 
@@ -611,6 +600,7 @@ class Extract(object):
 
         self._is_single = False
         self._data = copy.deepcopy(self._data)
+
         # iterate over the input dictionary or geographical object
         for key in list(self._data):
             # based on the geom type the right function is serialized
diff --git a/topojson/core/hashmap.py b/topojson/core/hashmap.py
index 67d3c73..2b334ae 100644
--- a/topojson/core/hashmap.py
+++ b/topojson/core/hashmap.py
@@ -105,7 +105,7 @@ class Hashmap(Dedup):
             objects["geometries"].append(feat)
 
         data["objects"] = {}
-        data["objects"]["data"] = objects
+        data["objects"][self.options.objects_name] = objects
 
         # prepare to return object
         data = self._data
diff --git a/topojson/core/topology.py b/topojson/core/topology.py
index 37d420a..b21b092 100644
--- a/topojson/core/topology.py
+++ b/topojson/core/topology.py
@@ -8,7 +8,9 @@ from ..ops import dequantize
 from ..ops import quantize
 from ..ops import simplify
 from ..ops import delta_encoding
+from ..ops import bounds
 from ..utils import TopoOptions
+from ..utils import instance
 from ..utils import serialize_as_svg
 from ..utils import serialize_as_json
 from ..utils import serialize_as_geojson
@@ -84,6 +86,10 @@ 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.
+    objects_name : 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`.
     """
 
     def __init__(
@@ -99,11 +105,34 @@ class Topology(Hashmap):
         simplify_with="shapely",
         simplify_algorithm="dp",
         winding_order="CW_CCW",
+        objects_name="data"
     ):
 
         options = TopoOptions(locals())
-        # execute previous steps
-        super().__init__(data, options)
+
+        # shorcut when dealing with topojson data
+         
+        if instance(data) == "dict" and 'type' in data.keys() and data['type'].casefold() == 'Topology'.casefold():
+            # change options to reflect this
+            options.prequantize = False
+            options.presimplify = False
+
+            arcs_asarray = [np.asarray(a) for a in data['arcs']]
+            parse_topo = {
+                "type": "Topology",
+                "linestrings": arcs_asarray,
+                "coordinates": [],
+                "options": options,
+                "bbox": bounds(arcs_asarray),
+                "objects": data['objects']
+            }
+            self.output = parse_topo
+            self.options = options
+
+        # all others follow normal route
+        else:
+            # execute previous steps
+            super().__init__(data, options)
 
         # execute main function of Topology
         self.output = self._topo(self.output)
@@ -114,7 +143,8 @@ class Topology(Hashmap):
     @property
     def __geo_interface__(self):
         topo_object = copy.deepcopy(self.output)
-        return serialize_as_geojson(topo_object, validate=False, objectname="data")
+        objectname = self.options.objects_name
+        return serialize_as_geojson(topo_object, validate=False, objectname=objectname)
 
     def to_dict(self, options=False):
         """
@@ -130,6 +160,8 @@ class Topology(Hashmap):
         topo_object = self._resolve_coords(topo_object)
         if options:
             topo_object["options"] = vars(self.options)
+        else:
+            topo_object.pop('options', None)          
         return topo_object
 
     def to_svg(self, separate=False):
@@ -173,6 +205,8 @@ class Topology(Hashmap):
 
         if options is True:
             topo_object["options"] = vars(self.options)
+        else:
+            topo_object.pop('options', None)
         return serialize_as_json(
             topo_object, fp, pretty=pretty, indent=indent, maxlinelength=maxlinelength
         )
@@ -184,8 +218,7 @@ class Topology(Hashmap):
         indent=4,
         maxlinelength=88,
         validate=False,
-        winding_order="CCW_CW",
-        objectname="data",
+        winding_order="CCW_CW"
     ):
         """
         Convert the Topology to a GeoJSON object. Remember that this will destroy the
@@ -217,12 +250,13 @@ class Topology(Hashmap):
             clockwise for interior rings. Or `CCW_CW` for counter-clockwise for outer
             rings and clockwise for interior rings.
             Default is `CCW_CW` for GeoJSON.
-        objectname : str
-            The name of the object within the Topology to convert to GeoJSON.
-            Default is `data`.
+        # objectname : str
+        #     The name of the object within the Topology to convert to GeoJSON.
+        #     Default is `data`.
         """
         topo_object = copy.deepcopy(self.output)
         topo_object = self._resolve_coords(topo_object)
+        objectname = self.options.objects_name
         fc = serialize_as_geojson(
             topo_object, validate=validate, objectname=objectname, order=winding_order
         )
@@ -231,7 +265,7 @@ class Topology(Hashmap):
         )
 
     def to_gdf(
-        self, crs=None, validate=False, winding_order="CCW_CW", objectname="data"
+        self, crs=None, validate=False, winding_order="CCW_CW"
     ):
         """
         Convert the Topology to a GeoDataFrame. Remember that this will destroy the
@@ -255,21 +289,22 @@ class Topology(Hashmap):
             clockwise for interior rings. Or `CCW_CW` for counter-clockwise for outer
             rings and clockwise for interior rings.
             Default is `CCW_CW` for GeoJSON.
-        objectname : str
-            The name of the object within the Topology to convert to GeoJSON.
-            Default is `data`.
+        # objectname : str
+        #     The name of the object within the Topology to convert to GeoJSON.
+        #     Default is `data`.
         """
         from ..utils import serialize_as_geodataframe
 
         topo_object = copy.deepcopy(self.output)
         topo_object = self._resolve_coords(topo_object)
+        objectname = self.options.objects_name
         fc = serialize_as_geojson(
             topo_object, validate=validate, objectname=objectname, order=winding_order
         )
         return serialize_as_geodataframe(fc, crs=crs)
 
     def to_alt(
-        self, color=None, tooltip=True, projection="identity", objectname="data"
+        self, color=None, tooltip=True, projection="identity"
     ):
         """
         Display as Altair visualization.
@@ -288,14 +323,15 @@ class Topology(Hashmap):
         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`.
+        # objectname : str
+        #     The name of the object within the Topology to display.
+        #     Default is `data`.
         """
         from ..utils import serialize_as_altair
 
-        topo_object = copy.deepcopy(self.output)
-        topo_object = self._resolve_coords(topo_object)
+        topo_object = self.to_json()
+        objectname = self.options.objects_name
+        
 
         return serialize_as_altair(topo_object, color, tooltip, projection, objectname)
 
@@ -488,7 +524,10 @@ class Topology(Hashmap):
             return result
 
     def _resolve_coords(self, data):
-        geoms = data["objects"]["data"]["geometries"]
+        objectname = self.options.objects_name
+        if not objectname 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"]:
 
diff --git a/topojson/utils.py b/topojson/utils.py
index 0616fa6..4e31148 100644
--- a/topojson/utils.py
+++ b/topojson/utils.py
@@ -25,6 +25,7 @@ class TopoOptions(object):
         simplify_with="shapely",
         simplify_algorithm="dp",
         winding_order=None,
+        objects_name="data"
     ):
         # get all arguments
         arguments = locals()
@@ -81,6 +82,11 @@ class TopoOptions(object):
         else:
             self.winding_order = None
 
+        if "objects_name" in arguments:
+            self.objects_name = arguments["objects_name"]
+        else:
+            self.objects_name = "data"            
+
     def __repr__(self):
         return "TopoOptions(\n  {}\n)".format(pprint.pformat(self.__dict__))
 
@@ -405,6 +411,17 @@ def serialize_as_svg(topo_object, separate=False, include_junctions=False):
 
 
 def serialize_as_json(topo_object, fp, pretty=False, indent=4, maxlinelength=88):
+    # https://stackoverflow.com/a/57915246
+    class NpEncoder(json.JSONEncoder):
+        def default(self, obj):
+            if isinstance(obj, np.integer):
+                return int(obj)
+            if isinstance(obj, np.floating):
+                return float(obj)
+            if isinstance(obj, np.ndarray):
+                return obj.tolist()
+            return super(NpEncoder, self).default(obj)   
+
     if fp:
         with open(fp, "w") as f:
             if pretty is True:
@@ -413,16 +430,16 @@ def serialize_as_json(topo_object, fp, pretty=False, indent=4, maxlinelength=88)
                     file=f,
                 )
             elif pretty is False:
-                json.dump(topo_object, separators=(",", ":"), fp=f)
+                json.dump(topo_object, separators=(",", ":"), fp=f, cls=NpEncoder)
             else:
-                json.dump(topo_object, fp=f)
+                json.dump(topo_object, fp=f, cls=NpEncoder)
     else:
         if pretty is True:
             return prettyjson(topo_object, indent=indent, maxlinelength=maxlinelength)
         elif pretty is False:
-            return json.dumps(topo_object, separators=(",", ":"))
+            return json.dumps(topo_object, separators=(",", ":"), cls=NpEncoder)
         else:
-            return json.dumps(topo_object)
+            return json.dumps(topo_object, cls=NpEncoder)
 
 
 def serialize_as_geojson(
@@ -447,6 +464,8 @@ def serialize_as_geojson(
         np_arcs = None
 
     # select object member from topology object
+    if not objectname in topo_object["objects"].keys():
+        raise SystemExit(f"'{objectname}' is not an object name in your topojson file")    
     features = topo_object["objects"][objectname]["geometries"]
 
     # prepare geojson featurecollection


Test patch:
diff --git a/tests/test_extract.py b/tests/test_extract.py
index a2f27cb..7284177 100644
--- a/tests/test_extract.py
+++ b/tests/test_extract.py
@@ -211,7 +211,6 @@ def test_extract_geopandas_geoseries():
     assert len(topo["objects"]) == 3
     assert len(topo["bookkeeping_geoms"]) == 3
     assert len(topo["linestrings"]) == 3
-    # TEST FAILS because of https://github.com/geopandas/geopandas/issues/1070
 
 
 # test shapely geometry collection.
@@ -362,6 +361,7 @@ def test_extract_fiona_file_gpkg():
 
     assert len(topo["bookkeeping_geoms"]) == 4
 
+# test to check if original data is not modified
 def test_extract_dict_org_data_untouched():
     data = {
         "foo": {"type": "LineString", "coordinates": [[0, 0], [1, 0], [2, 0]]},
@@ -374,6 +374,7 @@ def test_extract_dict_org_data_untouched():
     assert 'arcs' in topo_foo.keys()
     assert 'arcs' not in data_foo.keys()
 
+# test to check if original data is not modified
 def test_extract_list_org_data_untouched():
     data = [
         geometry.Polygon([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]),
@@ -386,6 +387,7 @@ def test_extract_list_org_data_untouched():
     assert 'arcs' in topo_0.keys()
     assert data_0.type == 'Polygon'
 
+# test to check if original data is not modified
 def test_extract_gdf_org_data_untouched():
     data = geopandas.read_file(
         "tests/files_geojson/naturalearth_alb_grc.geojson", driver="GeoJSON"
@@ -397,6 +399,7 @@ def test_extract_gdf_org_data_untouched():
     assert 'arcs' in topo_0.keys()
     assert data_0.geometry.type == 'Polygon'
 
+# test to check if original data is not modified
 def test_extract_shapely_org_data_untouched():
     data = geometry.LineString([[0, 0], [1, 0], [1, 1], [0, 1]])
     topo = Extract(data).to_dict()  
@@ -405,6 +408,7 @@ def test_extract_shapely_org_data_untouched():
     assert 'arcs' in topo_0.keys()
     assert data.type == 'LineString'    
 
+# test to check if original data is not modified
 def test_extract_shapefile_org_data_untouched():
     import shapefile
 
@@ -414,4 +418,4 @@ def test_extract_shapefile_org_data_untouched():
     data_0 = data.__geo_interface__['features'][0]['geometry']
 
     assert 'arcs' in topo_0.keys()
-    assert 'arcs' not in data_0.keys() 
\ No newline at end of file
+    assert 'arcs' not in data_0.keys()  
\ No newline at end of file
diff --git a/tests/test_topology.py b/tests/test_topology.py
index ecbdecb..4dbec2c 100644
--- a/tests/test_topology.py
+++ b/tests/test_topology.py
@@ -35,7 +35,7 @@ def test_topology_winding_order_TopoOptions():
     topo = topojson.Topology(data, winding_order="CW_CCW").to_dict(options=True)
 
     assert len(topo["objects"]) == 1
-    assert len(topo["options"]) == 10
+    assert len(topo["options"]) == 11
 
 
 # test winding order using kwarg variables
@@ -46,7 +46,7 @@ def test_topology_winding_order_kwarg_vars():
     topo = topojson.Topology(data, winding_order="CW_CCW").to_dict(options=True)
 
     assert len(topo["objects"]) == 1
-    assert len(topo["options"]) == 10
+    assert len(topo["options"]) == 11
 
 
 def test_topology_computing_topology():
@@ -440,4 +440,38 @@ def test_topology_topoquantization_dups():
     topo = topo.topoquantize(50).to_dict()
 
     assert topo['arcs'][6] == [[44, 47], [0, 0]]
- 
\ No newline at end of file
+ 
+# parse topojson from file
+def test_topology_topojson_from_file():
+    with open("tests/files_topojson/naturalearth.topojson", 'r') as f:
+        data = json.load(f)
+
+    topo = topojson.Topology(data).to_dict()
+
+    assert len(topo["objects"]) == 1   
+
+# parse topojson file and plot with altair
+def test_topology_topojson_to_alt():
+    # load topojson file into dict
+    with open("tests/files_topojson/naturalearth_lowres_africa.topojson", 'r') as f:
+        data = json.load(f)
+        
+    # parse topojson file using `objects_name`
+    topo = topojson.Topology(data, objects_name="data")
+    # apply toposimplify and serialize to altair
+    chart = topo.toposimplify(1).to_alt()    
+
+    assert len(chart.__dict__.keys()) == 2
+
+# Object of type int64 is not JSON serializable
+def test_topology_topojson_to_alt_int64():
+    # load topojson file into dict
+    with open("tests/files_topojson/mesh2d.topojson", 'r') as f:
+        data = json.load(f)    
+
+    # parse topojson file using `objects_name`
+    topo = topojson.Topology(data, objects_name="mesh2d_flowelem_bl")
+    # apply toposimplify and serialize to altair
+    chart = topo.toposimplify(1).to_alt()  
+
+    assert len(chart.__dict__.keys()) == 2       
\ No newline at end of file