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.
- topolib/elements/__init__.py +0 -3
- topolib/elements/link.py +3 -27
- topolib/elements/node.py +11 -2
- topolib/topology/topology.py +143 -47
- topolib/visualization/mapview.py +768 -8
- {topolib-0.5.0.dist-info → topolib-0.7.0.dist-info}/METADATA +7 -4
- {topolib-0.5.0.dist-info → topolib-0.7.0.dist-info}/RECORD +9 -9
- {topolib-0.5.0.dist-info → topolib-0.7.0.dist-info}/WHEEL +0 -0
- {topolib-0.5.0.dist-info → topolib-0.7.0.dist-info}/licenses/LICENSE +0 -0
topolib/elements/__init__.py
CHANGED
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
|
+
)
|
topolib/topology/topology.py
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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"]],
|
|
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
|
-
|
|
232
|
+
Export the current topology to the JSON format used in the assets folder.
|
|
221
233
|
|
|
222
|
-
:param file_path:
|
|
234
|
+
:param file_path: Path where the JSON file will be saved.
|
|
223
235
|
:type file_path: str
|
|
224
236
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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",
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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,
|
|
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(
|
|
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)
|
topolib/visualization/mapview.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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.
|
|
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.
|
|
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
|
[](https://www.python.org/)
|
|
@@ -50,8 +49,12 @@ Description-Content-Type: text/markdown
|
|
|
50
49
|
|
|
51
50
|
## 📂 Examples
|
|
52
51
|
|
|
53
|
-
|
|
54
|
-
-
|
|
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=
|
|
37
|
-
topolib/elements/link.py,sha256=
|
|
38
|
-
topolib/elements/node.py,sha256=
|
|
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=
|
|
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=
|
|
44
|
-
topolib-0.
|
|
45
|
-
topolib-0.
|
|
46
|
-
topolib-0.
|
|
47
|
-
topolib-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|