topolib 0.5.0__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of topolib might be problematic. Click here for more details.

@@ -2,7 +2,4 @@
2
2
  from .node import Node
3
3
  from .link import Link
4
4
 
5
- # Hide re-exported Link from Sphinx index to avoid duplicate warnings
6
- Link.__doc__ = """.. :noindex:"""
7
-
8
5
  __all__ = ["Node", "Link"]
topolib/elements/link.py CHANGED
@@ -7,10 +7,7 @@ if TYPE_CHECKING:
7
7
 
8
8
 
9
9
  class Link:
10
-
11
10
  """
12
- .. :noindex:
13
-
14
11
  Represents a link between two nodes.
15
12
 
16
13
  Parameters
@@ -24,17 +21,6 @@ class Link:
24
21
  length : float
25
22
  Length of the link (must be non-negative).
26
23
 
27
- Attributes
28
- ----------
29
- id : int
30
- Unique identifier for the link.
31
- source : :class:`topolib.elements.node.Node`
32
- Source node of the link.
33
- target : :class:`topolib.elements.node.Node`
34
- Target node of the link.
35
- length : float
36
- Length of the link.
37
-
38
24
  Examples
39
25
  --------
40
26
  >>> link = Link(1, nodeA, nodeB, 10.5)
@@ -50,8 +36,6 @@ class Link:
50
36
  @property
51
37
  def id(self) -> int:
52
38
  """
53
- .. :noindex:
54
-
55
39
  int: Unique identifier for the link.
56
40
  """
57
41
  return self._id
@@ -66,8 +50,6 @@ class Link:
66
50
  @property
67
51
  def source(self) -> "Node":
68
52
  """
69
- .. :noindex:
70
-
71
53
  :class:`topolib.elements.node.Node`: Source node of the link.
72
54
  """
73
55
  return self._source
@@ -80,15 +62,12 @@ class Link:
80
62
  required_attrs = ("id", "name", "latitude", "longitude")
81
63
  for attr in required_attrs:
82
64
  if not hasattr(value, attr):
83
- raise TypeError(
84
- f"source must behave like a Node (missing {attr})")
65
+ raise TypeError(f"source must behave like a Node (missing {attr})")
85
66
  self._source = value
86
67
 
87
68
  @property
88
69
  def target(self) -> "Node":
89
70
  """
90
- .. :noindex:
91
-
92
71
  :class:`topolib.elements.node.Node`: Target node of the link.
93
72
  """
94
73
  return self._target
@@ -101,15 +80,12 @@ class Link:
101
80
  required_attrs = ("id", "name", "latitude", "longitude")
102
81
  for attr in required_attrs:
103
82
  if not hasattr(value, attr):
104
- raise TypeError(
105
- f"target must behave like a Node (missing {attr})")
83
+ raise TypeError(f"target must behave like a Node (missing {attr})")
106
84
  self._target = value
107
85
 
108
86
  @property
109
87
  def length(self) -> float:
110
88
  """
111
- .. :noindex:
112
-
113
89
  float: Length of the link (non-negative).
114
90
  """
115
91
  return self._length
@@ -142,4 +118,4 @@ class Link:
142
118
  """
143
119
  Return a string representation of the Link.
144
120
  """
145
- return f"Link(id={self._id}, source={self._source.id}, target={self._target.id}, length={self._length})"
121
+ return f"Link(id={self._id}, source={self._source.id} ({self.source.name}), target={self._target.id} ({self.target.name}), length={self._length})"
topolib/elements/node.py CHANGED
@@ -9,8 +9,6 @@ from typing import Tuple
9
9
 
10
10
  class Node:
11
11
  """
12
- .. :noindex:
13
-
14
12
  Represents a node in an optical network topology.
15
13
 
16
14
  :param id: Unique identifier for the node.
@@ -219,3 +217,14 @@ class Node:
219
217
  :rtype: Tuple[float, float]
220
218
  """
221
219
  return self._latitude, self._longitude
220
+
221
+ def __repr__(self) -> str:
222
+ """Return a concise representation of the Node.
223
+
224
+ Includes id, name, latitude, longitude and additional attributes.
225
+ """
226
+ return (
227
+ f"Node(id={self._id}, name={self._name!r}, latitude={self._latitude}, "
228
+ f"longitude={self._longitude}, weight={self._weight}, pop={self._pop}, "
229
+ f"dc={self._dc}, ixp={self._ixp})"
230
+ )
@@ -10,12 +10,23 @@ https://github.com/networkx/networkx/blob/main/LICENSE.txt
10
10
 
11
11
  from typing import List, Optional, Any
12
12
  from numpy.typing import NDArray
13
- import numpy as np
14
- import networkx as nx
13
+
14
+
15
+ # Standard library imports
15
16
  import json
16
- import jsonschema
17
17
  import csv
18
+ from pathlib import Path
19
+
20
+ # Third-party imports
21
+ import numpy as np
18
22
  import networkx as nx
23
+ import jsonschema
24
+ import matplotlib.pyplot as plt
25
+ import contextily as ctx
26
+ import geopandas as gpd
27
+ from shapely.geometry import Point
28
+
29
+ # Local imports
19
30
  from topolib.elements.node import Node
20
31
  from topolib.elements.link import Link
21
32
 
@@ -144,7 +155,8 @@ class Topology:
144
155
  # Crear un dict para mapear id a Node
145
156
  node_dict = {n.id: n for n in nodes}
146
157
  links = [
147
- Link(l["id"], node_dict[l["src"]], node_dict[l["dst"]], l["length"])
158
+ Link(l["id"], node_dict[l["src"]],
159
+ node_dict[l["dst"]], l["length"])
148
160
  for l in data["links"]
149
161
  ]
150
162
  name = data.get("name", None)
@@ -217,40 +229,35 @@ class Topology:
217
229
 
218
230
  def export_to_json(self, file_path: str) -> None:
219
231
  """
220
- Exporta la topología actual al formato JSON usado en la carpeta assets.
232
+ Export the current topology to the JSON format used in the assets folder.
221
233
 
222
- :param file_path: Ruta donde se guardará el archivo JSON.
234
+ :param file_path: Path where the JSON file will be saved.
223
235
  :type file_path: str
224
236
 
225
- **Ejemplo de uso**
226
- >>> topo.export_to_json("/ruta/salida.json")
227
-
228
- **Ejemplo de formato de salida**
229
- {
230
- "name": "Abilene",
231
- "nodes": [
232
- {
233
- "id": 0,
234
- "name": "Seattle",
235
- "weight": 0,
236
- "longitude": -122.3328481,
237
- "latitude": 47.6061389,
238
- "pop": 780995,
239
- "DC": 58,
240
- "IXP": 5
241
- },
242
- # ...
243
- ],
244
- "links": [
245
- {
246
- "id": 0,
247
- "src": 0,
248
- "dst": 1,
249
- "length": 1482.26
250
- },
251
- # ...
252
- ]
253
- }
237
+ Example usage::
238
+
239
+ topo.export_to_json("/path/output.json")
240
+
241
+ Example output format::
242
+
243
+ {
244
+ "name": "Abilene",
245
+ "nodes": [
246
+ {
247
+ "id": 0,
248
+ "name": "Seattle",
249
+ ...
250
+ }
251
+ ],
252
+ "links": [
253
+ {
254
+ "id": 0,
255
+ "src": 0,
256
+ "dst": 1,
257
+ "length": 1482.26
258
+ }
259
+ ]
260
+ }
254
261
  """
255
262
  nodes_list: list[dict[str, Any]] = []
256
263
  for n in self.nodes:
@@ -300,7 +307,8 @@ class Topology:
300
307
  writer = csv.writer(f)
301
308
  # Header
302
309
  writer.writerow(
303
- ["id", "name", "weight", "latitude", "longitude", "pop", "DC", "IXP"]
310
+ ["id", "name", "weight", "latitude",
311
+ "longitude", "pop", "DC", "IXP"]
304
312
  )
305
313
  for n in self.nodes:
306
314
  writer.writerow(
@@ -376,21 +384,23 @@ class Topology:
376
384
  :param k: Number of shortest paths to compute for each node pair (default: 3).
377
385
  :type k: int
378
386
 
379
- The output format matches Flex Net Sim's routes.json:
380
- {
381
- "name": self.name,
382
- "alias": self.name,
383
- "routes": [
384
- {"src": <id>, "dst": <id>, "paths": [[id, ...], ...]},
385
- ...
386
- ]
387
- }
387
+ Example output format::
388
+
389
+ {
390
+ "name": self.name,
391
+ "alias": self.name,
392
+ "routes": [
393
+ {"src": <id>, "dst": <id>, "paths": [[id, ...], ...]},
394
+ ...
395
+ ]
396
+ }
388
397
  """
389
398
 
390
399
  # Build a weighted graph using link length as edge weight
391
400
  G = nx.DiGraph()
392
401
  for l in self.links:
393
- G.add_edge(l.source.id, l.target.id, weight=getattr(l, "length", 1))
402
+ G.add_edge(l.source.id, l.target.id,
403
+ weight=getattr(l, "length", 1))
394
404
  routes = []
395
405
  node_ids = [n.id for n in self.nodes]
396
406
  for src in node_ids:
@@ -399,7 +409,8 @@ class Topology:
399
409
  continue
400
410
  try:
401
411
  # Compute k shortest paths using link length as weight
402
- paths_gen = nx.shortest_simple_paths(G, src, dst, weight="weight")
412
+ paths_gen = nx.shortest_simple_paths(
413
+ G, src, dst, weight="weight")
403
414
  paths = []
404
415
  for i, path in enumerate(paths_gen):
405
416
  if i >= k:
@@ -415,3 +426,88 @@ class Topology:
415
426
  }
416
427
  with open(file_path, "w", encoding="utf-8") as f:
417
428
  json.dump(data, f, indent=4, ensure_ascii=False)
429
+
430
+ @classmethod
431
+ def list_available_topologies(cls) -> list[dict]:
432
+ """
433
+ List available topologies in the assets folder.
434
+
435
+ Returns a list of dictionaries with keys:
436
+ - 'name': topology name (filename without extension)
437
+ - 'nodes': number of nodes
438
+ - 'links': number of links
439
+
440
+ :return: List of available topologies with metadata.
441
+ :rtype: list[dict]
442
+ """
443
+ asset_dir = Path(__file__).parent.parent / "assets"
444
+ result = []
445
+ for json_path in asset_dir.glob("*.json"):
446
+ try:
447
+ topo = cls.from_json(str(json_path))
448
+ result.append({
449
+ "name": json_path.stem,
450
+ "nodes": len(topo.nodes),
451
+ "links": len(topo.links),
452
+ })
453
+ except Exception:
454
+ continue
455
+ return result
456
+
457
+ @staticmethod
458
+ def load_default_topology(name: str) -> "Topology":
459
+ """
460
+ Load a default topology from the assets folder by name (filename without extension).
461
+
462
+ :param name: Name of the topology asset (without .json extension)
463
+ :type name: str
464
+ :return: Topology instance loaded from the asset file
465
+ :rtype: Topology
466
+ :raises FileNotFoundError: If the asset file does not exist
467
+ """
468
+ asset_dir = Path(__file__).parent.parent / "assets"
469
+ asset_path = asset_dir / f"{name}.json"
470
+ if not asset_path.exists():
471
+ raise FileNotFoundError(
472
+ f"Topology asset '{name}.json' not found in assets directory.")
473
+ return Topology.from_json(str(asset_path))
474
+
475
+ def export_graph_png(self, filename: str, dpi: int = 150) -> None:
476
+ """
477
+ Export the topology graph as a PNG image using Matplotlib and Contextily.
478
+
479
+ :param filename: Output PNG file path.
480
+ :type filename: str
481
+ :param dpi: Dots per inch for the saved image (default: 150).
482
+ :type dpi: int
483
+ """
484
+ lons = [node.longitude for node in self.nodes]
485
+ lats = [node.latitude for node in self.nodes]
486
+ names = [node.name for node in self.nodes]
487
+ gdf = gpd.GeoDataFrame(
488
+ {"name": names},
489
+ geometry=[Point(x, y) for x, y in zip(lons, lats)],
490
+ crs="EPSG:4326",
491
+ )
492
+ gdf = gdf.to_crs(epsg=3857)
493
+ node_id_to_xy = {node.id: (pt.x, pt.y)
494
+ for node, pt in zip(self.nodes, gdf.geometry)}
495
+ topo_name = self.name if self.name else "Topology"
496
+ fig, ax = plt.subplots(figsize=(10, 7))
497
+ fig.suptitle(topo_name, fontsize=16)
498
+ for link in self.links:
499
+ src_id = link.source.id
500
+ tgt_id = link.target.id
501
+ if src_id in node_id_to_xy and tgt_id in node_id_to_xy:
502
+ x0, y0 = node_id_to_xy[src_id]
503
+ x1, y1 = node_id_to_xy[tgt_id]
504
+ ax.plot([x0, x1], [y0, y1], color="gray",
505
+ linewidth=1, alpha=0.7, zorder=2)
506
+ gdf.plot(ax=ax, color="blue", markersize=40, zorder=5)
507
+ for x, y, name in zip(gdf.geometry.x, gdf.geometry.y, gdf["name"]):
508
+ ax.text(x, y, name, fontsize=8, ha="right",
509
+ va="bottom", color="black")
510
+ ax.set_axis_off()
511
+ plt.tight_layout()
512
+ plt.savefig(filename, dpi=dpi)
513
+ plt.close(fig)
@@ -4,30 +4,120 @@ MapView class for visualizing network topologies.
4
4
  This module provides visualization methods for Topology objects.
5
5
  """
6
6
 
7
- from typing import Any
7
+ from typing import Any, Optional
8
8
  from topolib.topology import Topology
9
9
  import matplotlib.pyplot as plt
10
+ from matplotlib.widgets import Button, TextBox
10
11
  import contextily as ctx
11
12
  import geopandas as gpd
12
13
  from shapely.geometry import Point
14
+ import pyproj
15
+ from topolib.elements.link import Link
16
+ from topolib.elements.node import Node
17
+ from matplotlib.widgets import Button as _ContButton
13
18
 
14
19
 
15
20
  class MapView:
21
+ def _add_zoom_buttons(self):
22
+ """Add custom zoom buttons to the figure.
23
+
24
+ Adds two small Matplotlib Button widgets to the figure to allow the
25
+ user to zoom in and out. The buttons are placed in figure coordinates
26
+ near the bottom-left corner.
27
+
28
+ This method mutates ``self._fig`` by adding new axes and stores the
29
+ created Button widgets on this instance.
30
+ """
31
+ # Zoom in button
32
+ zoomin_ax = self._fig.add_axes([0.01, 0.01, 0.08, 0.06])
33
+ self._zoomin_btn = Button(zoomin_ax, "+", color="lightgray", hovercolor="0.975")
34
+ self._zoomin_btn.on_clicked(self._on_zoom_in)
35
+
36
+ # Zoom out button
37
+ zoomout_ax = self._fig.add_axes([0.10, 0.01, 0.08, 0.06])
38
+ self._zoomout_btn = Button(
39
+ zoomout_ax, "-", color="lightgray", hovercolor="0.975"
40
+ )
41
+ self._zoomout_btn.on_clicked(self._on_zoom_out)
42
+
43
+ def _on_zoom_in(self, event):
44
+ """Zoom in one step.
45
+
46
+ This is a short wrapper that calls :meth:`_zoom` with a scale factor
47
+ greater than 1 to zoom into the map center.
48
+
49
+ :param event: Matplotlib click event (ignored).
50
+ :type event: matplotlib.backend_bases.Event
51
+ """
52
+ self._zoom(1.2)
53
+
54
+ def _on_zoom_out(self, event):
55
+ """Zoom out one step.
56
+
57
+ Wrapper for :meth:`_zoom` that uses a scale factor smaller than 1.
58
+
59
+ :param event: Matplotlib click event (ignored).
60
+ :type event: matplotlib.backend_bases.Event
61
+ """
62
+ self._zoom(1 / 1.2)
63
+
64
+ def _zoom(self, scale):
65
+ """Zoom the map by a scale factor while keeping the current center.
66
+
67
+ The method computes new axis limits that are scaled around the
68
+ current center of ``self._ax`` and triggers a redraw of the map
69
+ content via :meth:`_redraw_map`.
70
+
71
+ :param scale: Scale factor (>1 zooms in, <1 zooms out)
72
+ :type scale: float
73
+ """
74
+ xlim = self._ax.get_xlim()
75
+ ylim = self._ax.get_ylim()
76
+ xmid = (xlim[0] + xlim[1]) / 2
77
+ ymid = (ylim[0] + ylim[1]) / 2
78
+ xsize = (xlim[1] - xlim[0]) / scale
79
+ ysize = (ylim[1] - ylim[0]) / scale
80
+ new_xlim = [xmid - xsize / 2, xmid + xsize / 2]
81
+ new_ylim = [ymid - ysize / 2, ymid + ysize / 2]
82
+ self._redraw_map(xlim=new_xlim, ylim=new_ylim)
83
+
16
84
  """
17
85
  Provides visualization methods for Topology objects.
18
86
  """
19
87
 
20
88
  def __init__(self, topology: Topology) -> None:
21
- """
22
- Initialize MapView with a Topology object.
23
- :param topology: Topology object
89
+ """Create a MapView instance for a :class:`topolib.topology.Topology`.
90
+
91
+ :param topology: Topology object to visualize and mutate.
92
+ :type topology: topolib.topology.Topology
24
93
  """
25
94
  self.topology = topology
95
+ # Interactive add node mode state
96
+ self._add_node_mode = False
97
+ self._add_node_button = None
98
+ self._cid_click = None
99
+ self._fig = None
100
+ self._ax = None
101
+ # Interactive add link mode state
102
+ self._add_link_mode = False
103
+ self._link_button = None
104
+ self._cid_link_click = None
105
+ self._link_start_node: Optional[object] = None
106
+ # Node form state
107
+ self._node_form_artists: list = []
108
+ # Artists used to show a provisional node on the map before confirmation
109
+ self._provisional_artists: list = []
26
110
 
27
111
  def show_map(self) -> None:
28
- """
29
- Show all nodes and links of the topology on an OpenStreetMap base using contextily and Matplotlib.
30
- The figure and plot title will be the topology name if available.
112
+ """Render the interactive map window with nodes and links.
113
+
114
+ This method prepares a Matplotlib figure and axes, renders all
115
+ current nodes and links projected to Web Mercator (EPSG:3857), adds
116
+ a contextily basemap, and installs the interactive widgets for
117
+ adding nodes/links and zooming. The figure is displayed with
118
+ :func:`matplotlib.pyplot.show`.
119
+
120
+ :returns: None
31
121
  """
32
122
  lons: list[float] = [node.longitude for node in self.topology.nodes]
33
123
  lats: list[float] = [node.latitude for node in self.topology.nodes]
@@ -53,7 +143,19 @@ class MapView:
53
143
  topo_name = "Topology"
54
144
 
55
145
  fig, ax = plt.subplots(figsize=(10, 7))
146
+ self._fig = fig
147
+ self._ax = ax
148
+ # Continue flag used by the 'Continue' button to close the map and resume execution
149
+ self._continue_pressed = False
56
150
  fig.suptitle(topo_name, fontsize=16)
151
+ # Hide the default matplotlib toolbar for a cleaner UI
152
+ try:
153
+ fig.canvas.manager.toolbar.hide()
154
+ except Exception:
155
+ try:
156
+ fig.canvas.toolbar_visible = False
157
+ except Exception:
158
+ pass
57
159
  # Draw links as simple lines
58
160
  for link in getattr(self.topology, "links", []):
59
161
  src_id = getattr(link, "source").id
@@ -66,10 +168,668 @@ class MapView:
66
168
  )
67
169
  # Draw nodes
68
170
  gdf.plot(ax=ax, color="blue", markersize=40, zorder=5)
171
+ for x, y, name in zip(gdf.geometry.x, gdf.geometry.y, gdf["name"]):
172
+ ax.text(
173
+ x,
174
+ y,
175
+ name,
176
+ fontsize=8,
177
+ ha="right",
178
+ va="bottom",
179
+ color="black",
180
+ clip_on=True,
181
+ )
182
+ ctx.add_basemap(ax, source=ctx.providers.OpenStreetMap.Mapnik)
183
+ ax.set_axis_off()
184
+ ax.set_title(f"Nodes and links ({topo_name})")
185
+ # plt.tight_layout() # Removed to avoid warning with widgets
186
+
187
+ # Add interactive button for 'Add Node' mode
188
+ self._add_interactive_add_node_button()
189
+ # Add zoom in/out buttons
190
+ self._add_zoom_buttons()
191
+ # Add interactive button for 'Add Link' mode
192
+ self._add_interactive_add_link_button()
193
+ # Add continue button to close the map and continue execution
194
+ self._add_continue_button()
195
+ plt.show()
196
+
197
+ def _add_interactive_add_node_button(self):
198
+ """Create and attach the "Add Node" Button widget.
199
+
200
+ When clicked the button enables a one-shot mode where the next
201
+ click on the map will place a provisional marker and open the
202
+ node-edit dialog.
203
+ """
204
+ # Place button in a new axes
205
+ button_ax = self._fig.add_axes([0.81, 0.01, 0.15, 0.06])
206
+ self._add_node_button = Button(
207
+ button_ax, "Add Node", color="lightgray", hovercolor="0.975"
208
+ )
209
+ self._add_node_button.on_clicked(self._on_add_node_button_clicked)
210
+
211
+ def _add_interactive_add_link_button(self):
212
+ """Create and attach the "Add Link" Button widget.
213
+
214
+ When enabled the map will accept two node selections and will
215
+ create a bidirectional link (two Link objects) between them.
216
+ """
217
+ # Place button in a new axes (left of Add Node button)
218
+ link_ax = self._fig.add_axes([0.63, 0.01, 0.15, 0.06])
219
+ self._link_button = Button(
220
+ link_ax, "Add Link", color="lightgray", hovercolor="0.975"
221
+ )
222
+ self._link_button.on_clicked(self._on_add_link_button_clicked)
223
+
224
+ def _add_continue_button(self):
225
+ """Add a 'Continue' button.
226
+
227
+ The button closes the map window and allows the calling code to
228
+ continue executing (``show_map`` returns after the figure
229
+ window is closed via this button).
230
+ """
231
+ cont_ax = self._fig.add_axes([0.81, 0.08, 0.15, 0.06])
232
+
233
+ self._cont_button = _ContButton(
234
+ cont_ax, "Continue", color="lightgray", hovercolor="0.975"
235
+ )
236
+ self._cont_button.on_clicked(self._on_continue_clicked)
237
+
238
+ def _on_continue_clicked(self, event):
239
+ """Handle clicks on the Continue button.
240
+
241
+ Sets an internal flag and attempts to close the Matplotlib figure.
242
+
243
+ :param event: Matplotlib click event (ignored)
244
+ :type event: matplotlib.backend_bases.Event
245
+ """
246
+ self._continue_pressed = True
247
+ try:
248
+ plt.close(self._fig)
249
+ except Exception:
250
+ pass
251
+
252
+ def _on_add_link_button_clicked(self, event):
253
+ """Toggle the persistent "Add Link" mode.
254
+
255
+ When the mode is active the map will listen to clicks and allow the
256
+ user to select two nodes to create a link. The button's appearance
257
+ is changed to provide feedback about the active state.
258
+
259
+ :param event: Matplotlib click event (ignored)
260
+ :type event: matplotlib.backend_bases.Event
261
+ """
262
+ self._add_link_mode = not self._add_link_mode
263
+ if self._add_link_mode:
264
+ # connect click handler
265
+ if self._cid_link_click is None:
266
+ self._cid_link_click = self._fig.canvas.mpl_connect(
267
+ "button_press_event", self._on_map_click_add_link
268
+ )
269
+ # visually mark button pressed by changing color
270
+ try:
271
+ self._link_button.color = "lightblue"
272
+ except Exception:
273
+ pass
274
+ self._link_start_node = None
275
+ else:
276
+ # disconnect handler
277
+ if self._cid_link_click is not None:
278
+ self._fig.canvas.mpl_disconnect(self._cid_link_click)
279
+ self._cid_link_click = None
280
+ try:
281
+ self._link_button.color = "lightgray"
282
+ except Exception:
283
+ pass
284
+ self._link_start_node = None
285
+
286
+ def _on_map_click_add_link(self, event):
287
+ """Handle map clicks while in "Add Link" mode.
288
+
289
+ The handler finds the nearest node to the click (within a tolerance)
290
+ and treats the first selection as a "start" node and the second
291
+ one as the "end" node. When both nodes are selected two Link
292
+ instances are created (forward and reverse) and added to the
293
+ topology. Link length is computed using geodesic distance (WGS84)
294
+ and stored in kilometers.
295
+
296
+ :param event: Matplotlib mouse event containing ``xdata``/``ydata``
297
+ :type event: matplotlib.backend_bases.MouseEvent
298
+ """
299
+ if not self._add_link_mode or event.inaxes != self._ax:
300
+ return
301
+ if event.xdata is None or event.ydata is None:
302
+ return
303
+
304
+ # We have projected coordinates in xdata,ydata (EPSG:3857)
305
+ click_x, click_y = event.xdata, event.ydata
306
+
307
+ # Build projected GeoDataFrame of nodes to find nearest
308
+ lons = [node.longitude for node in self.topology.nodes]
309
+ lats = [node.latitude for node in self.topology.nodes]
310
+ gdf_nodes = gpd.GeoDataFrame(
311
+ {"id": [n.id for n in self.topology.nodes]},
312
+ geometry=[Point(x, y) for x, y in zip(lons, lats)],
313
+ crs="EPSG:4326",
314
+ ).to_crs(epsg=3857)
315
+
316
+ # Find the nearest node within a tolerance (in meters/pixels depending on map scale).
317
+ # We'll use a simple distance-based tolerance (e.g., 10000 meters) but adjust if necessary.
318
+ tol = 100000 # meters in projected coordinates; conservative default
319
+ nearest = None
320
+ min_dist = float("inf")
321
+ for idx, geom in enumerate(gdf_nodes.geometry):
322
+ dx = geom.x - click_x
323
+ dy = geom.y - click_y
324
+ dist = (dx * dx + dy * dy) ** 0.5
325
+ if dist < min_dist and dist <= tol:
326
+ min_dist = dist
327
+ nearest = self.topology.nodes[idx]
328
+
329
+ if nearest is None:
330
+ # nothing clicked near a node
331
+ return
332
+
333
+ # If start node not selected yet, set it and highlight
334
+ if self._link_start_node is None:
335
+ self._link_start_node = nearest
336
+ # optionally highlight by drawing a red marker
337
+ self._ax.plot(
338
+ [gdf_nodes.geometry[self.topology.nodes.index(nearest)].x],
339
+ [gdf_nodes.geometry[self.topology.nodes.index(nearest)].y],
340
+ marker="o",
341
+ color="red",
342
+ markersize=12,
343
+ zorder=10,
344
+ )
345
+ self._fig.canvas.draw_idle()
346
+ return
347
+
348
+ # If start selected and clicked on a different node, create link
349
+ if nearest.id != self._link_start_node.id:
350
+ # determine new link ids (reserve two consecutive ids)
351
+ max_id = max([l.id for l in self.topology.links], default=0)
352
+ id1 = max_id + 1
353
+ id2 = max_id + 2
354
+
355
+ # compute geodesic distance between the two nodes (meters)
356
+ lon1 = self._link_start_node.longitude
357
+ lat1 = self._link_start_node.latitude
358
+ lon2 = nearest.longitude
359
+ lat2 = nearest.latitude
360
+ geod = pyproj.Geod(ellps="WGS84")
361
+ _, _, distance = geod.inv(lon1, lat1, lon2, lat2)
362
+ distance_km = distance / 1000.0
363
+
364
+ # create forward and reverse links with same length (in km)
365
+ new_link = Link(id1, self._link_start_node, nearest, distance_km)
366
+ new_link_rev = Link(id2, nearest, self._link_start_node, distance_km)
367
+ self.topology.add_link(new_link)
368
+ self.topology.add_link(new_link_rev)
369
+ # redraw map to show new links
370
+ self._redraw_map()
371
+
372
+ # Reset start node selection but keep add-link mode active until toggled
373
+ self._link_start_node = None
374
+
375
+ def _on_add_node_button_clicked(self, event):
376
+ """Enable "Add Node" mode and wait for a map click.
377
+
378
+ After this method runs, the next valid click inside the map axes
379
+ will place a provisional marker and open the node editor dialog.
380
+
381
+ :param event: Matplotlib click event (ignored)
382
+ :type event: matplotlib.backend_bases.Event
383
+ """
384
+ self._add_node_mode = True
385
+ # Connect click event if not already connected
386
+ if self._cid_click is None:
387
+ self._cid_click = self._fig.canvas.mpl_connect(
388
+ "button_press_event", self._on_map_click_add_node
389
+ )
390
+ # Now waiting for a click on the map to place the provisional marker and open the dialog
391
+
392
+ def _on_map_click_add_node(self, event):
393
+ """Handle a map click when in "Add Node" mode.
394
+
395
+ Places a provisional marker and label at the clicked position (in
396
+ Web Mercator coordinates), computes the corresponding longitude
397
+ and latitude and opens a modal dialog to edit the new node
398
+ attributes. The provisional marker is removed when the dialog is
399
+ closed (confirmed or cancelled).
400
+
401
+ :param event: Matplotlib mouse event (must have ``xdata`` and ``ydata``)
402
+ :type event: matplotlib.backend_bases.MouseEvent
403
+ """
404
+ if not self._add_node_mode or event.inaxes != self._ax:
405
+ return
406
+ if event.xdata is None or event.ydata is None:
407
+ return
408
+
409
+ # Convert from Web Mercator (EPSG:3857) to lon/lat (EPSG:4326)
410
+ proj_3857 = pyproj.CRS("EPSG:3857")
411
+ proj_4326 = pyproj.CRS("EPSG:4326")
412
+ transformer = pyproj.Transformer.from_crs(proj_3857, proj_4326, always_xy=True)
413
+ lon, lat = transformer.transform(event.xdata, event.ydata)
414
+
415
+ # Prepare default id and name
416
+ next_id = max([n.id for n in self.topology.nodes], default=0) + 1
417
+ default_name = f"new node {next_id}"
418
+
419
+ # Draw a provisional node marker and label on the map so the user sees it immediately
420
+ try:
421
+ marker_line = self._ax.plot(
422
+ [event.xdata],
423
+ [event.ydata],
424
+ marker="o",
425
+ color="green",
426
+ markersize=10,
427
+ zorder=20,
428
+ )[0]
429
+ txt = self._ax.text(
430
+ event.xdata,
431
+ event.ydata,
432
+ default_name,
433
+ fontsize=8,
434
+ ha="right",
435
+ va="bottom",
436
+ color="green",
437
+ zorder=21,
438
+ )
439
+ self._provisional_artists = [marker_line, txt]
440
+ try:
441
+ self._fig.canvas.draw()
442
+ except Exception:
443
+ try:
444
+ self._fig.canvas.draw_idle()
445
+ except Exception:
446
+ pass
447
+ # Give the GUI event loop a small chance to render the canvas so the provisional
448
+ # marker becomes visible before opening the modal tkinter dialog.
449
+ try:
450
+ plt.pause(0.05)
451
+ except Exception:
452
+ try:
453
+ plt.pause(0.01)
454
+ except Exception:
455
+ pass
456
+ except Exception:
457
+ self._provisional_artists = []
458
+
459
+ # Show interactive form to enter node details (prefill id, name, lon/lat)
460
+ self._show_node_form(
461
+ event.xdata,
462
+ event.ydata,
463
+ lon,
464
+ lat,
465
+ prefill_id=next_id,
466
+ prefill_name=default_name,
467
+ )
468
+
469
+ # After showing form, disable add-node mode (user must re-click button to add more)
470
+ self._add_node_mode = False
471
+ if self._cid_click is not None:
472
+ self._fig.canvas.mpl_disconnect(self._cid_click)
473
+ self._cid_click = None
474
+
475
+ def _close_node_form(self):
476
+ """Remove any Matplotlib widgets/axes used by the node form.
477
+
478
+ This method attempts to remove stored widget axes from the
479
+ figure (if any) and requests a canvas redraw.
480
+ """
481
+ for a in self._node_form_artists:
482
+ try:
483
+ a.remove()
484
+ except Exception:
485
+ pass
486
+ self._node_form_artists = []
487
+ try:
488
+ self._fig.canvas.draw_idle()
489
+ except Exception:
490
+ pass
491
+
492
+ def _show_node_form(
493
+ self,
494
+ x_proj,
495
+ y_proj,
496
+ lon_prefill,
497
+ lat_prefill,
498
+ prefill_id: Optional[int] = None,
499
+ prefill_name: Optional[str] = None,
500
+ ):
501
+ """Open a native tkinter modal dialog to edit node attributes.
502
+
503
+ This dialog is modal and will block until the user confirms or
504
+ cancels. The dialog is pre-filled with the provided longitude and
505
+ latitude and a suggested id and name. If the dialog is confirmed
506
+ a new :class:`topolib.elements.node.Node` is created and added to
507
+ the topology; otherwise the provisional marker is removed.
508
+
509
+ Note: tkinter must be available in the Python environment. On
510
+ many Linux distributions the package providing tkinter is called
511
+ ``python3-tk``.
512
+
513
+ :param x_proj: Click X coordinate in Web Mercator (EPSG:3857).
514
+ :param y_proj: Click Y coordinate in Web Mercator (EPSG:3857).
515
+ :param lon_prefill: Suggested longitude (EPSG:4326).
516
+ :param lat_prefill: Suggested latitude (EPSG:4326).
517
+ :param prefill_id: Suggested node id, if any.
518
+ :param prefill_name: Suggested node name, if any.
519
+ :returns: None
520
+ """
521
+ # Use a native tkinter modal dialog for reliable popup behavior across backends.
522
+ try:
523
+ import tkinter as tk
524
+
525
+ # Prepare defaults
526
+ if prefill_id is None:
527
+ prefill_id = max([n.id for n in self.topology.nodes], default=0) + 1
528
+ if prefill_name is None:
529
+ prefill_name = f"new node {prefill_id}"
530
+
531
+ root = tk.Tk()
532
+ root.withdraw() # hide main root
533
+
534
+ dialog = tk.Toplevel(root)
535
+ dialog.title("Add node")
536
+ dialog.resizable(False, False)
537
+ dialog.grab_set() # modal
538
+
539
+ # Labels and entries
540
+ tk.Label(dialog, text="ID").grid(
541
+ row=0, column=0, sticky="e", padx=6, pady=4
542
+ )
543
+ en_id = tk.Entry(dialog)
544
+ en_id.insert(0, str(prefill_id))
545
+ en_id.grid(row=0, column=1, padx=6, pady=4)
546
+
547
+ tk.Label(dialog, text="Name").grid(
548
+ row=1, column=0, sticky="e", padx=6, pady=4
549
+ )
550
+ en_name = tk.Entry(dialog)
551
+ en_name.insert(0, prefill_name)
552
+ en_name.grid(row=1, column=1, padx=6, pady=4)
553
+
554
+ tk.Label(dialog, text="Latitude").grid(
555
+ row=2, column=0, sticky="e", padx=6, pady=4
556
+ )
557
+ en_lat = tk.Entry(dialog)
558
+ en_lat.insert(0, str(lat_prefill))
559
+ en_lat.grid(row=2, column=1, padx=6, pady=4)
560
+
561
+ tk.Label(dialog, text="Longitude").grid(
562
+ row=3, column=0, sticky="e", padx=6, pady=4
563
+ )
564
+ en_lon = tk.Entry(dialog)
565
+ en_lon.insert(0, str(lon_prefill))
566
+ en_lon.grid(row=3, column=1, padx=6, pady=4)
567
+
568
+ # Additional node attributes (initialize to zero)
569
+ tk.Label(dialog, text="Weight").grid(
570
+ row=4, column=0, sticky="e", padx=6, pady=4
571
+ )
572
+ en_weight = tk.Entry(dialog)
573
+ en_weight.insert(0, "0")
574
+ en_weight.grid(row=4, column=1, padx=6, pady=4)
575
+
576
+ tk.Label(dialog, text="Pop").grid(
577
+ row=5, column=0, sticky="e", padx=6, pady=4
578
+ )
579
+ en_pop = tk.Entry(dialog)
580
+ en_pop.insert(0, "0")
581
+ en_pop.grid(row=5, column=1, padx=6, pady=4)
582
+
583
+ tk.Label(dialog, text="DC").grid(
584
+ row=6, column=0, sticky="e", padx=6, pady=4
585
+ )
586
+ en_dc = tk.Entry(dialog)
587
+ en_dc.insert(0, "0")
588
+ en_dc.grid(row=6, column=1, padx=6, pady=4)
589
+
590
+ tk.Label(dialog, text="IXP").grid(
591
+ row=7, column=0, sticky="e", padx=6, pady=4
592
+ )
593
+ en_ixp = tk.Entry(dialog)
594
+ en_ixp.insert(0, "0")
595
+ en_ixp.grid(row=7, column=1, padx=6, pady=4)
596
+
597
+ result = {"ok": False}
598
+
599
+ def on_confirm():
600
+ try:
601
+ nid = int(en_id.get().strip())
602
+ name = en_name.get().strip() or f"new node {nid}"
603
+ lat = float(en_lat.get().strip())
604
+ lon = float(en_lon.get().strip())
605
+ except Exception:
606
+ # invalid input: ignore (or add dialog message)
607
+ return
608
+ # parse additional attributes, default to 0 on parse error
609
+ try:
610
+ weight = float(en_weight.get().strip())
611
+ except Exception:
612
+ weight = 0
613
+ try:
614
+ pop = int(en_pop.get().strip())
615
+ except Exception:
616
+ pop = 0
617
+ try:
618
+ dc = int(en_dc.get().strip())
619
+ except Exception:
620
+ dc = 0
621
+ try:
622
+ ixp = int(en_ixp.get().strip())
623
+ except Exception:
624
+ ixp = 0
625
+
626
+ # Check id uniqueness
627
+ if any(n.id == nid for n in self.topology.nodes):
628
+ return
629
+ # Create and add node with additional attributes
630
+ new_node = Node(nid, name, lat, lon, weight, pop, dc, ixp)
631
+ self.topology.add_node(new_node)
632
+ result.update({"ok": True})
633
+ dialog.destroy()
634
+
635
+ def on_cancel():
636
+ dialog.destroy()
637
+
638
+ btn_frame = tk.Frame(dialog)
639
+ btn_frame.grid(row=8, column=0, columnspan=2, pady=(4, 8))
640
+ tk.Button(btn_frame, text="Confirm", command=on_confirm, bg="#9fdf9f").pack(
641
+ side="left", padx=6
642
+ )
643
+ tk.Button(btn_frame, text="Cancel", command=on_cancel).pack(
644
+ side="right", padx=6
645
+ )
646
+
647
+ # Center the dialog relative to the main figure window if possible
648
+ try:
649
+ mgr = getattr(self._fig.canvas, "manager", None)
650
+ if mgr is not None:
651
+ win = getattr(mgr, "window", None)
652
+ if win is not None and hasattr(win, "winfo_rootx"):
653
+ # For TkAgg integration, position near the plot window
654
+ try:
655
+ x = win.winfo_rootx() + 50
656
+ y = win.winfo_rooty() + 50
657
+ dialog.geometry(f"+{x}+{y}")
658
+ # make dialog transient/child of the plot window and lift it
659
+ try:
660
+ dialog.transient(win)
661
+ dialog.lift()
662
+ dialog.attributes("-topmost", True)
663
+ dialog.attributes("-topmost", False)
664
+ except Exception:
665
+ try:
666
+ dialog.lift()
667
+ except Exception:
668
+ pass
669
+ except Exception:
670
+ pass
671
+ except Exception:
672
+ pass
673
+
674
+ root.wait_window(dialog)
675
+ try:
676
+ root.destroy()
677
+ except Exception:
678
+ pass
679
+
680
+ # If the dialog confirmed and node added, redraw map
681
+ if result.get("ok"):
682
+ try:
683
+ # clear provisional artists (they will be redrawn from topology)
684
+ for a in self._provisional_artists:
685
+ try:
686
+ a.remove()
687
+ except Exception:
688
+ pass
689
+ self._provisional_artists = []
690
+ except Exception:
691
+ pass
692
+ try:
693
+ self._redraw_map()
694
+ except Exception:
695
+ pass
696
+ else:
697
+ # user cancelled: remove provisional marker and redraw to erase it
698
+ try:
699
+ for a in self._provisional_artists:
700
+ try:
701
+ a.remove()
702
+ except Exception:
703
+ pass
704
+ self._provisional_artists = []
705
+ except Exception:
706
+ pass
707
+ try:
708
+ self._fig.canvas.draw()
709
+ except Exception:
710
+ try:
711
+ self._fig.canvas.draw_idle()
712
+ except Exception:
713
+ pass
714
+ return
715
+ except Exception:
716
+ # Fallback: do nothing if tkinter not available
717
+ return
718
+
719
+ def _redraw_map(self, xlim=None, ylim=None):
720
+ """Redraw the map content (nodes and links) on the existing axes.
721
+
722
+ The axes are cleared and nodes/links are re-plotted from the
723
+ current topology. Optionally, axis limits can be supplied so the
724
+ view is preserved after redraw.
725
+
726
+ :param xlim: Optional 2-tuple with x-axis limits in projected units.
727
+ :type xlim: Optional[tuple(float, float)]
728
+ :param ylim: Optional 2-tuple with y-axis limits in projected units.
729
+ :type ylim: Optional[tuple(float, float)]
730
+ :returns: None
731
+ """
732
+ # geopandas, shapely Point and contextily already imported at module level
733
+
734
+ # Clear axes
735
+ self._ax.clear()
736
+ if xlim is not None:
737
+ self._ax.set_xlim(xlim)
738
+ if ylim is not None:
739
+ self._ax.set_ylim(ylim)
740
+ lons = [node.longitude for node in self.topology.nodes]
741
+ lats = [node.latitude for node in self.topology.nodes]
742
+ names = [node.name for node in self.topology.nodes]
743
+ gdf = gpd.GeoDataFrame(
744
+ {"name": names},
745
+ geometry=[Point(x, y) for x, y in zip(lons, lats)],
746
+ crs="EPSG:4326",
747
+ )
748
+ gdf = gdf.to_crs(epsg=3857)
749
+ node_id_to_xy = {
750
+ node.id: (pt.x, pt.y) for node, pt in zip(self.topology.nodes, gdf.geometry)
751
+ }
752
+ topo_name = getattr(self.topology, "name", None)
753
+ if topo_name is None:
754
+ topo_name = getattr(self.topology, "_name", None)
755
+ if topo_name is None:
756
+ topo_name = "Topology"
757
+ # Draw links
758
+ for link in getattr(self.topology, "links", []):
759
+ src_id = getattr(link, "source").id
760
+ tgt_id = getattr(link, "target").id
761
+ if src_id in node_id_to_xy and tgt_id in node_id_to_xy:
762
+ x0, y0 = node_id_to_xy[src_id]
763
+ x1, y1 = node_id_to_xy[tgt_id]
764
+ self._ax.plot(
765
+ [x0, x1], [y0, y1], color="gray", linewidth=1, alpha=0.7, zorder=2
766
+ )
767
+ # Draw nodes
768
+ gdf.plot(ax=self._ax, color="blue", markersize=40, zorder=5)
769
+ for x, y, name in zip(gdf.geometry.x, gdf.geometry.y, gdf["name"]):
770
+ self._ax.text(
771
+ x,
772
+ y,
773
+ name,
774
+ fontsize=8,
775
+ ha="right",
776
+ va="bottom",
777
+ color="black",
778
+ clip_on=True,
779
+ )
780
+ ctx.add_basemap(self._ax, source=ctx.providers.OpenStreetMap.Mapnik)
781
+ self._ax.set_axis_off()
782
+ self._ax.set_title(f"Nodes and links ({topo_name})")
783
+ self._fig.suptitle(topo_name, fontsize=16)
784
+ # self._fig.tight_layout() # Removed to avoid warning with widgets
785
+ self._fig.canvas.draw_idle()
786
+
787
+ def export_map_png(self, filename: str, dpi: int = 150) -> None:
788
+ """
789
+ Export the topology map as a PNG image using Matplotlib and Contextily.
790
+
791
+ :param filename: Output PNG file path.
792
+ :type filename: str
793
+ :param dpi: Dots per inch for the saved image (default: 150).
794
+ :type dpi: int
795
+ """
796
+ lons: list[float] = [node.longitude for node in self.topology.nodes]
797
+ lats: list[float] = [node.latitude for node in self.topology.nodes]
798
+ names: list[str] = [node.name for node in self.topology.nodes]
799
+ gdf: gpd.GeoDataFrame = gpd.GeoDataFrame(
800
+ {"name": names},
801
+ geometry=[Point(x, y) for x, y in zip(lons, lats)],
802
+ crs="EPSG:4326",
803
+ )
804
+ gdf = gdf.to_crs(epsg=3857)
805
+
806
+ node_id_to_xy = {
807
+ node.id: (pt.x, pt.y) for node, pt in zip(self.topology.nodes, gdf.geometry)
808
+ }
809
+
810
+ topo_name = getattr(self.topology, "name", None)
811
+ if topo_name is None:
812
+ topo_name = getattr(self.topology, "_name", None)
813
+ if topo_name is None:
814
+ topo_name = "Topology"
815
+
816
+ fig, ax = plt.subplots(figsize=(10, 7))
817
+ fig.suptitle(topo_name, fontsize=16)
818
+ for link in getattr(self.topology, "links", []):
819
+ src_id = getattr(link, "source").id
820
+ tgt_id = getattr(link, "target").id
821
+ if src_id in node_id_to_xy and tgt_id in node_id_to_xy:
822
+ x0, y0 = node_id_to_xy[src_id]
823
+ x1, y1 = node_id_to_xy[tgt_id]
824
+ ax.plot(
825
+ [x0, x1], [y0, y1], color="gray", linewidth=1, alpha=0.7, zorder=2
826
+ )
827
+ gdf.plot(ax=ax, color="blue", markersize=40, zorder=5)
69
828
  for x, y, name in zip(gdf.geometry.x, gdf.geometry.y, gdf["name"]):
70
829
  ax.text(x, y, name, fontsize=8, ha="right", va="bottom", color="black")
71
830
  ctx.add_basemap(ax, source=ctx.providers.OpenStreetMap.Mapnik)
72
831
  ax.set_axis_off()
73
832
  ax.set_title(f"Nodes and links ({topo_name})")
74
833
  plt.tight_layout()
75
- plt.show()
834
+ plt.savefig(filename, dpi=dpi)
835
+ plt.close(fig)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: topolib
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: A compact Python library for modeling, analyzing, and visualizing optical network topologies.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -31,7 +31,6 @@ Project-URL: Homepage, https://gitlab.com/DaniloBorquez/topolib
31
31
  Project-URL: Repository, https://gitlab.com/DaniloBorquez/topolib
32
32
  Description-Content-Type: text/markdown
33
33
 
34
- # Topolib 🚀
35
34
  # Topolib 🚀
36
35
 
37
36
  [![Python Version](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/)
@@ -50,8 +49,12 @@ Description-Content-Type: text/markdown
50
49
 
51
50
  ## 📂 Examples
52
51
 
53
- Explore ready-to-run usage examples in the [`examples/`](examples/) folder!
54
- - [Show topology on a map](examples/show_topology_in_map.py) 🗺️
52
+
53
+ Explore ready-to-run usage examples in the [`examples/`](examples/) folder!
54
+
55
+ - [Show topology on a map](examples/show_topology_in_map.py) 🗺️
56
+ - [Show default topology in map](examples/show_default_topology_in_map.py) 🗺️
57
+ - [Export topology as PNG](examples/export_topology_png.py) 🖼️
55
58
  - [Export topology to CSV and JSON](examples/export_csv_json.py) 📄
56
59
  - [Export topology and k-shortest paths for FlexNetSim](examples/export_flexnetsim.py) 🔀
57
60
 
@@ -33,15 +33,15 @@ topolib/assets/Telefonica-21.json,sha256=Wom5SQn3lLL-z-ESZ3Du_Fz_ZQ2bzP2_bNhzmnm
33
33
  topolib/assets/Turk_Telekom.json,sha256=5yO0cgtJOmh6ulCCIb8g_nDFSmv8Wn52Yt_Oyoc1Y0w,11533
34
34
  topolib/assets/UKNet.json,sha256=9BEkRM9YFeMMpih5kyDY34TsOGFzt5KBGsmgWd6KoB0,14210
35
35
  topolib/assets/Vega_Telecom.json,sha256=E07ZCvG4exRj1a5DV8lqS3Sdo5tRm_Lc07IzIjP2EW0,17324
36
- topolib/elements/__init__.py,sha256=ZWSd5uwVtppJtMnZ30zrAdzXhUAYIK62RrUaH9rsWu0,180
37
- topolib/elements/link.py,sha256=BwjdCR8peh7dDpKunZodlIsjKcfvwjKxyQ3tmp1obb8,3778
38
- topolib/elements/node.py,sha256=uOnljwA3jcVR4utUfI-om4SIt_QBsEQt8QLW9uvjr3Y,4974
36
+ topolib/elements/__init__.py,sha256=RIQGFgI3_R7Saf679LaP8o8D8kVtM-JadZt6XufJcQ4,75
37
+ topolib/elements/link.py,sha256=2YDEkKdCt91Nv8oiv6gfyECWoeYQaShyhi5DGRvKN2o,3376
38
+ topolib/elements/node.py,sha256=ytxxYvIV9kcsnEV8R0IEFpaXT3-cL5g-djcSkcesBOI,5374
39
39
  topolib/topology/__init__.py,sha256=2VRhVm4ZvKBiTDB2T8BDmLZBpwGCVFRF3fvtxxC_d28,86
40
40
  topolib/topology/path.py,sha256=oUNwmpBcS6LMMAJIxokROm3MVqr7vRR44M3Fh5ADq_w,2057
41
- topolib/topology/topology.py,sha256=pRmbn8vaFh35Fi0dtSy-fl_3VNVz6MqW-iqfeh3BvPw,14896
41
+ topolib/topology/topology.py,sha256=LfMfBtxCu8GIJJG3Dmmw_hIQwDWDTeDA31zwOpBcVnc,18354
42
42
  topolib/visualization/__init__.py,sha256=wv065-KB5uDbTaQIASPVfMMW5sE76Bs-q0oai48vAzk,29
43
- topolib/visualization/mapview.py,sha256=4iKJMYsH6oFNj9tOcgSKLVRtoYfUuH3XLjShsGwUXcM,2799
44
- topolib-0.5.0.dist-info/METADATA,sha256=8jZNgjgAyWsX3d63Lxo5bI6T6JDCAmKUbu_PQ2svtXM,4169
45
- topolib-0.5.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
46
- topolib-0.5.0.dist-info/licenses/LICENSE,sha256=kbnIP0XU6f2ualiTjEawdlU81IGPBbwc-_GF3N-1e9E,1081
47
- topolib-0.5.0.dist-info/RECORD,,
43
+ topolib/visualization/mapview.py,sha256=7oZsA0Dam8DJfS_59Csvi1y68Do142D44zj9vIS9PBg,32631
44
+ topolib-0.7.0.dist-info/METADATA,sha256=U-fMdGPorQ-CoAu3I1tbt36UHIiW7xnolmPFFhDnGE4,4303
45
+ topolib-0.7.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
46
+ topolib-0.7.0.dist-info/licenses/LICENSE,sha256=kbnIP0XU6f2ualiTjEawdlU81IGPBbwc-_GF3N-1e9E,1081
47
+ topolib-0.7.0.dist-info/RECORD,,