unifi-network-maps 1.4.0__py3-none-any.whl → 1.4.2__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.
- unifi_network_maps/__init__.py +1 -1
- unifi_network_maps/__main__.py +8 -0
- unifi_network_maps/adapters/unifi.py +266 -24
- unifi_network_maps/cli/main.py +352 -107
- unifi_network_maps/io/debug.py +15 -5
- unifi_network_maps/io/export.py +20 -1
- unifi_network_maps/model/topology.py +125 -71
- unifi_network_maps/render/device_ports_md.py +31 -18
- unifi_network_maps/render/lldp_md.py +87 -43
- unifi_network_maps/render/mermaid.py +96 -49
- unifi_network_maps/render/svg.py +614 -318
- {unifi_network_maps-1.4.0.dist-info → unifi_network_maps-1.4.2.dist-info}/METADATA +57 -82
- {unifi_network_maps-1.4.0.dist-info → unifi_network_maps-1.4.2.dist-info}/RECORD +17 -16
- {unifi_network_maps-1.4.0.dist-info → unifi_network_maps-1.4.2.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.4.0.dist-info → unifi_network_maps-1.4.2.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.0.dist-info → unifi_network_maps-1.4.2.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.0.dist-info → unifi_network_maps-1.4.2.dist-info}/top_level.txt +0 -0
unifi_network_maps/render/svg.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
6
|
import math
|
|
7
|
+
from collections.abc import Callable
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
@@ -38,6 +39,17 @@ class IsoLayout:
|
|
|
38
39
|
extra_pad: float
|
|
39
40
|
|
|
40
41
|
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class IsoLayoutPositions:
|
|
44
|
+
layout: IsoLayout
|
|
45
|
+
grid_positions: dict[str, tuple[float, float]]
|
|
46
|
+
positions: dict[str, tuple[float, float]]
|
|
47
|
+
width: float
|
|
48
|
+
height: float
|
|
49
|
+
offset_x: float
|
|
50
|
+
offset_y: float
|
|
51
|
+
|
|
52
|
+
|
|
41
53
|
def _iso_layout(options: SvgOptions) -> IsoLayout:
|
|
42
54
|
tile_width = options.node_width * 1.5
|
|
43
55
|
iso_angle = math.radians(30.0)
|
|
@@ -112,14 +124,13 @@ def _compact_edge_label(
|
|
|
112
124
|
) -> str:
|
|
113
125
|
if "<->" not in label:
|
|
114
126
|
return label
|
|
115
|
-
|
|
116
|
-
left_name = _extract_device_name(
|
|
117
|
-
right_name = _extract_device_name(
|
|
118
|
-
left_port = _extract_port_text(
|
|
119
|
-
right_port = _extract_port_text(
|
|
127
|
+
left_segment, right_segment = (part.strip() for part in label.split("<->", 1))
|
|
128
|
+
left_name = _extract_device_name(left_segment)
|
|
129
|
+
right_name = _extract_device_name(right_segment)
|
|
130
|
+
left_port = _extract_port_text(left_segment)
|
|
131
|
+
right_port = _extract_port_text(right_segment)
|
|
120
132
|
if left_node and right_node:
|
|
121
133
|
if right_name and right_name == left_node and left_name == right_node:
|
|
122
|
-
left, right = right, left
|
|
123
134
|
left_name, right_name = right_name, left_name
|
|
124
135
|
left_port, right_port = right_port, left_port
|
|
125
136
|
if left_port and right_port:
|
|
@@ -353,34 +364,59 @@ def _layout_nodes(
|
|
|
353
364
|
return positions, width, height
|
|
354
365
|
|
|
355
366
|
|
|
356
|
-
def
|
|
357
|
-
edges: list[Edge], node_types: dict[str, str]
|
|
358
|
-
) -> tuple[dict[str, float], dict[str, int]]:
|
|
367
|
+
def _layout_nodeset(edges: list[Edge], node_types: dict[str, str]) -> set[str]:
|
|
359
368
|
nodes = set(node_types.keys())
|
|
360
369
|
for edge in edges:
|
|
361
370
|
nodes.add(edge.left)
|
|
362
371
|
nodes.add(edge.right)
|
|
372
|
+
return nodes
|
|
373
|
+
|
|
363
374
|
|
|
375
|
+
def _build_children_maps(
|
|
376
|
+
edges: list[Edge], nodes: set[str]
|
|
377
|
+
) -> tuple[dict[str, list[str]], dict[str, int]]:
|
|
364
378
|
children: dict[str, list[str]] = {name: [] for name in nodes}
|
|
365
379
|
incoming: dict[str, int] = {name: 0 for name in nodes}
|
|
366
380
|
for edge in edges:
|
|
367
381
|
children[edge.left].append(edge.right)
|
|
368
382
|
incoming[edge.right] = incoming.get(edge.right, 0) + 1
|
|
383
|
+
return children, incoming
|
|
369
384
|
|
|
385
|
+
|
|
386
|
+
def _sort_key_for_nodes(node_types: dict[str, str]) -> Callable[[str], tuple[int, str]]:
|
|
370
387
|
type_order = {t: i for i, t in enumerate(_TYPE_ORDER)}
|
|
371
388
|
|
|
372
389
|
def sort_key(name: str) -> tuple[int, str]:
|
|
373
390
|
return (type_order.get(node_types.get(name, "other"), 99), name.lower())
|
|
374
391
|
|
|
392
|
+
return sort_key
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _sort_children(children: dict[str, list[str]], sort_key) -> None:
|
|
375
396
|
for _parent, child_list in children.items():
|
|
376
397
|
child_list.sort(key=sort_key)
|
|
377
398
|
|
|
399
|
+
|
|
400
|
+
def _resolve_roots(
|
|
401
|
+
nodes: set[str],
|
|
402
|
+
incoming: dict[str, int],
|
|
403
|
+
node_types: dict[str, str],
|
|
404
|
+
sort_key,
|
|
405
|
+
) -> list[str]:
|
|
378
406
|
gateways = [n for n, t in node_types.items() if t == "gateway"]
|
|
379
407
|
roots = gateways if gateways else [n for n in nodes if incoming.get(n, 0) == 0]
|
|
380
408
|
if not roots:
|
|
381
409
|
roots = list(nodes)
|
|
382
|
-
|
|
410
|
+
return sorted(roots, key=sort_key)
|
|
383
411
|
|
|
412
|
+
|
|
413
|
+
def _layout_positions(
|
|
414
|
+
nodes: set[str],
|
|
415
|
+
children: dict[str, list[str]],
|
|
416
|
+
*,
|
|
417
|
+
roots: list[str],
|
|
418
|
+
sort_key,
|
|
419
|
+
) -> tuple[dict[str, float], dict[str, int]]:
|
|
384
420
|
levels: dict[str, int] = {}
|
|
385
421
|
positions_index: dict[str, float] = {}
|
|
386
422
|
visited: set[str] = set()
|
|
@@ -415,14 +451,23 @@ def _tree_layout_indices(
|
|
|
415
451
|
|
|
416
452
|
for root in roots:
|
|
417
453
|
dfs(root, 0)
|
|
418
|
-
|
|
419
454
|
for node in sorted(nodes, key=sort_key):
|
|
420
455
|
if node not in positions_index:
|
|
421
456
|
dfs(node, 0)
|
|
422
|
-
|
|
423
457
|
return positions_index, levels
|
|
424
458
|
|
|
425
459
|
|
|
460
|
+
def _tree_layout_indices(
|
|
461
|
+
edges: list[Edge], node_types: dict[str, str]
|
|
462
|
+
) -> tuple[dict[str, float], dict[str, int]]:
|
|
463
|
+
nodes = _layout_nodeset(edges, node_types)
|
|
464
|
+
children, incoming = _build_children_maps(edges, nodes)
|
|
465
|
+
sort_key = _sort_key_for_nodes(node_types)
|
|
466
|
+
_sort_children(children, sort_key)
|
|
467
|
+
roots = _resolve_roots(nodes, incoming, node_types, sort_key)
|
|
468
|
+
return _layout_positions(nodes, children, roots=roots, sort_key=sort_key)
|
|
469
|
+
|
|
470
|
+
|
|
426
471
|
def render_svg(
|
|
427
472
|
edges: list[Edge],
|
|
428
473
|
*,
|
|
@@ -440,9 +485,37 @@ def render_svg(
|
|
|
440
485
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
|
|
441
486
|
f'viewBox="0 0 {width} {height}">',
|
|
442
487
|
svg_defs("", theme),
|
|
443
|
-
|
|
488
|
+
(
|
|
489
|
+
"<style>text{font-family:Arial,Helvetica,sans-serif;font-size:"
|
|
490
|
+
f"{options.font_size}px;"
|
|
491
|
+
"}</style>"
|
|
492
|
+
),
|
|
444
493
|
]
|
|
445
494
|
|
|
495
|
+
node_port_labels, node_port_prefix = _render_svg_edges(
|
|
496
|
+
lines, edges, positions, node_types, options
|
|
497
|
+
)
|
|
498
|
+
_render_svg_nodes(
|
|
499
|
+
lines,
|
|
500
|
+
positions,
|
|
501
|
+
node_types,
|
|
502
|
+
node_port_labels,
|
|
503
|
+
node_port_prefix,
|
|
504
|
+
icons,
|
|
505
|
+
options,
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
lines.append("</svg>")
|
|
509
|
+
return "\n".join(lines) + "\n"
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _render_svg_edges(
|
|
513
|
+
lines: list[str],
|
|
514
|
+
edges: list[Edge],
|
|
515
|
+
positions: dict[str, tuple[float, float]],
|
|
516
|
+
node_types: dict[str, str],
|
|
517
|
+
options: SvgOptions,
|
|
518
|
+
) -> tuple[dict[str, str], dict[str, str]]:
|
|
446
519
|
node_port_labels: dict[str, str] = {}
|
|
447
520
|
node_port_prefix: dict[str, str] = {}
|
|
448
521
|
for edge in edges:
|
|
@@ -469,34 +542,54 @@ def render_svg(
|
|
|
469
542
|
f'<text x="{icon_x}" y="{icon_y}" text-anchor="middle" fill="#1e88e5" '
|
|
470
543
|
f'font-size="{max(options.font_size, 10)}">⚡</text>'
|
|
471
544
|
)
|
|
545
|
+
_record_edge_labels(edge, node_types, node_port_labels, node_port_prefix)
|
|
546
|
+
return node_port_labels, node_port_prefix
|
|
472
547
|
|
|
473
|
-
if edge.label:
|
|
474
|
-
label_text = _compact_edge_label(edge.label, left_node=edge.left, right_node=edge.right)
|
|
475
|
-
left_type = node_types.get(edge.left, "other")
|
|
476
|
-
right_type = node_types.get(edge.right, "other")
|
|
477
|
-
client_node = None
|
|
478
|
-
upstream_node = None
|
|
479
|
-
if left_type == "client" and right_type != "client":
|
|
480
|
-
client_node = edge.left
|
|
481
|
-
upstream_node = edge.right
|
|
482
|
-
elif right_type == "client" and left_type != "client":
|
|
483
|
-
client_node = edge.right
|
|
484
|
-
upstream_node = edge.left
|
|
485
|
-
if client_node and upstream_node:
|
|
486
|
-
if "<->" not in label_text:
|
|
487
|
-
upstream_part = edge.label.split("<->", 1)[0].strip()
|
|
488
|
-
port_text = _extract_port_text(upstream_part) or label_text
|
|
489
|
-
upstream_name = _extract_device_name(upstream_part) or upstream_node
|
|
490
|
-
node_port_labels.setdefault(client_node, f"{upstream_name}: {port_text}")
|
|
491
|
-
node_port_prefix.setdefault(client_node, upstream_name)
|
|
492
|
-
else:
|
|
493
|
-
upstream_part = edge.label.split("<->", 1)[0].strip()
|
|
494
|
-
upstream_name = _extract_device_name(upstream_part) or edge.left
|
|
495
|
-
if label_text.lower().startswith("port "):
|
|
496
|
-
label_text = f"{upstream_name} {label_text}"
|
|
497
|
-
node_port_labels.setdefault(edge.right, label_text)
|
|
498
|
-
node_port_prefix.setdefault(edge.right, upstream_name)
|
|
499
548
|
|
|
549
|
+
def _record_edge_labels(
|
|
550
|
+
edge: Edge,
|
|
551
|
+
node_types: dict[str, str],
|
|
552
|
+
node_port_labels: dict[str, str],
|
|
553
|
+
node_port_prefix: dict[str, str],
|
|
554
|
+
) -> None:
|
|
555
|
+
if not edge.label:
|
|
556
|
+
return
|
|
557
|
+
label_text = _compact_edge_label(edge.label, left_node=edge.left, right_node=edge.right)
|
|
558
|
+
left_type = node_types.get(edge.left, "other")
|
|
559
|
+
right_type = node_types.get(edge.right, "other")
|
|
560
|
+
client_node = None
|
|
561
|
+
upstream_node = None
|
|
562
|
+
if left_type == "client" and right_type != "client":
|
|
563
|
+
client_node = edge.left
|
|
564
|
+
upstream_node = edge.right
|
|
565
|
+
elif right_type == "client" and left_type != "client":
|
|
566
|
+
client_node = edge.right
|
|
567
|
+
upstream_node = edge.left
|
|
568
|
+
if client_node and upstream_node:
|
|
569
|
+
if "<->" not in label_text:
|
|
570
|
+
upstream_part = edge.label.split("<->", 1)[0].strip()
|
|
571
|
+
port_text = _extract_port_text(upstream_part) or label_text
|
|
572
|
+
upstream_name = _extract_device_name(upstream_part) or upstream_node
|
|
573
|
+
node_port_labels.setdefault(client_node, f"{upstream_name}: {port_text}")
|
|
574
|
+
node_port_prefix.setdefault(client_node, upstream_name)
|
|
575
|
+
return
|
|
576
|
+
upstream_part = edge.label.split("<->", 1)[0].strip()
|
|
577
|
+
upstream_name = _extract_device_name(upstream_part) or edge.left
|
|
578
|
+
if label_text.lower().startswith("port "):
|
|
579
|
+
label_text = f"{upstream_name} {label_text}"
|
|
580
|
+
node_port_labels.setdefault(edge.right, label_text)
|
|
581
|
+
node_port_prefix.setdefault(edge.right, upstream_name)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _render_svg_nodes(
|
|
585
|
+
lines: list[str],
|
|
586
|
+
positions: dict[str, tuple[float, float]],
|
|
587
|
+
node_types: dict[str, str],
|
|
588
|
+
node_port_labels: dict[str, str],
|
|
589
|
+
node_port_prefix: dict[str, str],
|
|
590
|
+
icons: dict[str, str],
|
|
591
|
+
options: SvgOptions,
|
|
592
|
+
) -> None:
|
|
500
593
|
for name, (x, y) in positions.items():
|
|
501
594
|
node_type = node_types.get(name, "other")
|
|
502
595
|
fill, stroke = _TYPE_COLORS.get(node_type, _TYPE_COLORS["other"])
|
|
@@ -539,49 +632,33 @@ def render_svg(
|
|
|
539
632
|
f'<text x="{text_x}" y="{text_y}" fill="#1f1f1f" text-anchor="start">{safe_name}</text>'
|
|
540
633
|
)
|
|
541
634
|
|
|
542
|
-
lines.append("</svg>")
|
|
543
|
-
return "\n".join(lines) + "\n"
|
|
544
635
|
|
|
636
|
+
def _iso_project(layout: IsoLayout, gx: float, gy: float) -> tuple[float, float]:
|
|
637
|
+
iso_x = (gx - gy) * (layout.step_width / 2)
|
|
638
|
+
iso_y = (gx + gy) * (layout.step_height / 2)
|
|
639
|
+
return iso_x, iso_y
|
|
545
640
|
|
|
546
|
-
|
|
641
|
+
|
|
642
|
+
def _iso_project_center(layout: IsoLayout, gx: float, gy: float) -> tuple[float, float]:
|
|
643
|
+
return _iso_project(layout, gx + 0.5, gy + 0.5)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _iso_layout_positions(
|
|
547
647
|
edges: list[Edge],
|
|
548
|
-
*,
|
|
549
648
|
node_types: dict[str, str],
|
|
550
|
-
options: SvgOptions
|
|
551
|
-
|
|
552
|
-
) -> str:
|
|
553
|
-
options = options or SvgOptions()
|
|
554
|
-
icons = _load_isometric_icons()
|
|
555
|
-
positions_index, levels = _tree_layout_indices(edges, node_types)
|
|
556
|
-
if not positions_index:
|
|
557
|
-
positions_index = {}
|
|
649
|
+
options: SvgOptions,
|
|
650
|
+
) -> IsoLayoutPositions:
|
|
558
651
|
layout = _iso_layout(options)
|
|
559
|
-
|
|
560
|
-
tile_h = layout.tile_height
|
|
561
|
-
step_w = layout.step_width
|
|
562
|
-
step_h = layout.step_height
|
|
563
|
-
grid_spacing_x = layout.grid_spacing_x
|
|
564
|
-
grid_spacing_y = layout.grid_spacing_y
|
|
565
|
-
|
|
652
|
+
positions_index, levels = _tree_layout_indices(edges, node_types)
|
|
566
653
|
grid_positions: dict[str, tuple[float, float]] = {}
|
|
567
654
|
positions: dict[str, tuple[float, float]] = {}
|
|
568
|
-
|
|
569
|
-
def project_iso(gx: float, gy: float) -> tuple[float, float]:
|
|
570
|
-
iso_x = (gx - gy) * (step_w / 2)
|
|
571
|
-
iso_y = (gx + gy) * (step_h / 2)
|
|
572
|
-
return iso_x, iso_y
|
|
573
|
-
|
|
574
|
-
def project_iso_center(gx: float, gy: float) -> tuple[float, float]:
|
|
575
|
-
return project_iso(gx + 0.5, gy + 0.5)
|
|
576
|
-
|
|
577
655
|
for name, idx in positions_index.items():
|
|
578
656
|
level = levels.get(name, 0)
|
|
579
|
-
gx = round(idx * grid_spacing_x)
|
|
580
|
-
gy = round(float(level) * grid_spacing_y)
|
|
657
|
+
gx = round(idx * layout.grid_spacing_x)
|
|
658
|
+
gy = round(float(level) * layout.grid_spacing_y)
|
|
581
659
|
grid_positions[name] = (float(gx), float(gy))
|
|
582
|
-
iso_x, iso_y =
|
|
660
|
+
iso_x, iso_y = _iso_project_center(layout, float(gx), float(gy))
|
|
583
661
|
positions[name] = (iso_x, iso_y)
|
|
584
|
-
|
|
585
662
|
if positions:
|
|
586
663
|
min_x = min(x for x, _ in positions.values())
|
|
587
664
|
min_y = min(y for _, y in positions.values())
|
|
@@ -590,77 +667,97 @@ def render_svg_isometric(
|
|
|
590
667
|
else:
|
|
591
668
|
min_x = min_y = 0.0
|
|
592
669
|
max_x = max_y = 0.0
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
tile_y_offset = layout.tile_y_offset
|
|
596
|
-
offset_x = -min_x + padding
|
|
597
|
-
offset_y = -min_y + padding + tile_y_offset
|
|
670
|
+
offset_x = -min_x + layout.padding
|
|
671
|
+
offset_y = -min_y + layout.padding + layout.tile_y_offset
|
|
598
672
|
for name, (x, y) in positions.items():
|
|
599
673
|
positions[name] = (x + offset_x, y + offset_y)
|
|
674
|
+
width = max_x - min_x + layout.tile_width + layout.padding * 2 + layout.extra_pad
|
|
675
|
+
height = (
|
|
676
|
+
max_y
|
|
677
|
+
- min_y
|
|
678
|
+
+ layout.tile_height
|
|
679
|
+
+ layout.padding * 2
|
|
680
|
+
+ layout.tile_y_offset
|
|
681
|
+
+ layout.extra_pad
|
|
682
|
+
)
|
|
683
|
+
return IsoLayoutPositions(
|
|
684
|
+
layout=layout,
|
|
685
|
+
grid_positions=grid_positions,
|
|
686
|
+
positions=positions,
|
|
687
|
+
width=width,
|
|
688
|
+
height=height,
|
|
689
|
+
offset_x=offset_x,
|
|
690
|
+
offset_y=offset_y,
|
|
691
|
+
)
|
|
600
692
|
|
|
601
|
-
def project_iso_center_padded(gx: float, gy: float) -> tuple[float, float]:
|
|
602
|
-
iso_x, iso_y = project_iso_center(gx, gy)
|
|
603
|
-
return iso_x + offset_x, iso_y + offset_y
|
|
604
|
-
|
|
605
|
-
def grid_center(gx: float, gy: float) -> tuple[float, float]:
|
|
606
|
-
cx, cy = project_iso_center_padded(gx, gy)
|
|
607
|
-
return cx + tile_w / 2, cy + tile_h / 2
|
|
608
|
-
|
|
609
|
-
def front_anchor(gx: float, gy: float) -> tuple[float, float]:
|
|
610
|
-
cx, cy = grid_center(gx, gy)
|
|
611
|
-
return cx, cy
|
|
612
|
-
|
|
613
|
-
width = max_x - min_x + tile_w + padding * 2 + layout.extra_pad
|
|
614
|
-
height = max_y - min_y + tile_h + padding * 2 + tile_y_offset + layout.extra_pad
|
|
615
693
|
|
|
616
|
-
|
|
617
|
-
|
|
694
|
+
def _iso_grid_lines(
|
|
695
|
+
grid_positions: dict[str, tuple[float, float]],
|
|
696
|
+
layout: IsoLayout,
|
|
697
|
+
) -> list[str]:
|
|
698
|
+
if not grid_positions:
|
|
699
|
+
return []
|
|
700
|
+
min_gx = min(gx for gx, _ in grid_positions.values())
|
|
701
|
+
max_gx = max(gx for gx, _ in grid_positions.values())
|
|
702
|
+
min_gy = min(gy for _, gy in grid_positions.values())
|
|
703
|
+
max_gy = max(gy for _, gy in grid_positions.values())
|
|
704
|
+
pad = 12
|
|
705
|
+
gx_start = int(math.floor(min_gx)) - pad
|
|
706
|
+
gx_end = int(math.ceil(max_gx)) + pad
|
|
707
|
+
gy_start = int(math.floor(min_gy)) - pad
|
|
708
|
+
gy_end = int(math.ceil(max_gy)) + pad
|
|
709
|
+
grid_lines: list[str] = []
|
|
710
|
+
for gx in range(gx_start, gx_end + 1):
|
|
711
|
+
x1, y1 = _iso_project(layout, float(gx), float(gy_start))
|
|
712
|
+
x2, y2 = _iso_project(layout, float(gx), float(gy_end))
|
|
713
|
+
x1 += layout.padding
|
|
714
|
+
y1 += layout.padding
|
|
715
|
+
x2 += layout.padding
|
|
716
|
+
y2 += layout.padding
|
|
717
|
+
grid_lines.append(
|
|
718
|
+
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#efefef" stroke-width="0.6"/>'
|
|
719
|
+
)
|
|
720
|
+
for gy in range(gy_start, gy_end + 1):
|
|
721
|
+
x1, y1 = _iso_project(layout, float(gx_start), float(gy))
|
|
722
|
+
x2, y2 = _iso_project(layout, float(gx_end), float(gy))
|
|
723
|
+
x1 += layout.padding
|
|
724
|
+
y1 += layout.padding
|
|
725
|
+
x2 += layout.padding
|
|
726
|
+
y2 += layout.padding
|
|
727
|
+
grid_lines.append(
|
|
728
|
+
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#efefef" stroke-width="0.6"/>'
|
|
729
|
+
)
|
|
730
|
+
return grid_lines
|
|
618
731
|
|
|
619
|
-
lines = [
|
|
620
|
-
f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
|
|
621
|
-
f'viewBox="0 0 {width} {height}">',
|
|
622
|
-
svg_defs("iso", theme),
|
|
623
|
-
f"<style>text{{font-family:Arial,Helvetica,sans-serif;font-size:{options.font_size}px;}}</style>",
|
|
624
|
-
]
|
|
625
732
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
x1, y1 = project_iso(float(gx), float(gy_start))
|
|
639
|
-
x2, y2 = project_iso(float(gx), float(gy_end))
|
|
640
|
-
x1 += padding
|
|
641
|
-
y1 += padding
|
|
642
|
-
x2 += padding
|
|
643
|
-
y2 += padding
|
|
644
|
-
grid_lines.append(
|
|
645
|
-
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#efefef" stroke-width="0.6"/>'
|
|
646
|
-
)
|
|
647
|
-
for gy in range(gy_start, gy_end + 1):
|
|
648
|
-
x1, y1 = project_iso(float(gx_start), float(gy))
|
|
649
|
-
x2, y2 = project_iso(float(gx_end), float(gy))
|
|
650
|
-
x1 += padding
|
|
651
|
-
y1 += padding
|
|
652
|
-
x2 += padding
|
|
653
|
-
y2 += padding
|
|
654
|
-
grid_lines.append(
|
|
655
|
-
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#efefef" stroke-width="0.6"/>'
|
|
656
|
-
)
|
|
657
|
-
lines.append('<g class="iso-grid" opacity="0.7">')
|
|
658
|
-
lines.extend(grid_lines)
|
|
659
|
-
lines.append("</g>")
|
|
733
|
+
def _iso_front_anchor(
|
|
734
|
+
layout: IsoLayout,
|
|
735
|
+
*,
|
|
736
|
+
gx: float,
|
|
737
|
+
gy: float,
|
|
738
|
+
offset_x: float,
|
|
739
|
+
offset_y: float,
|
|
740
|
+
) -> tuple[float, float]:
|
|
741
|
+
iso_x, iso_y = _iso_project_center(layout, gx, gy)
|
|
742
|
+
cx = iso_x + offset_x + layout.tile_width / 2
|
|
743
|
+
cy = iso_y + offset_y + layout.tile_height / 2
|
|
744
|
+
return cx, cy
|
|
660
745
|
|
|
661
|
-
node_port_labels: dict[str, str] = {}
|
|
662
|
-
node_port_prefix: dict[str, str] = {}
|
|
663
746
|
|
|
747
|
+
def _render_iso_edges(
|
|
748
|
+
lines: list[str],
|
|
749
|
+
edges: list[Edge],
|
|
750
|
+
*,
|
|
751
|
+
positions: dict[str, tuple[float, float]],
|
|
752
|
+
grid_positions: dict[str, tuple[float, float]],
|
|
753
|
+
node_types: dict[str, str],
|
|
754
|
+
layout: IsoLayout,
|
|
755
|
+
options: SvgOptions,
|
|
756
|
+
offset_x: float,
|
|
757
|
+
offset_y: float,
|
|
758
|
+
node_port_labels: dict[str, str],
|
|
759
|
+
node_port_prefix: dict[str, str],
|
|
760
|
+
) -> None:
|
|
664
761
|
for edge in edges:
|
|
665
762
|
if edge.left not in positions or edge.right not in positions:
|
|
666
763
|
continue
|
|
@@ -672,210 +769,409 @@ def render_svg_isometric(
|
|
|
672
769
|
width_px = 5 if edge.poe else 4
|
|
673
770
|
src_gx, src_gy = float(src_grid[0]), float(src_grid[1])
|
|
674
771
|
dst_gx, dst_gy = float(dst_grid[0]), float(dst_grid[1])
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
dst_cx, dst_cy =
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
772
|
+
src_cx, src_cy = _iso_front_anchor(
|
|
773
|
+
layout, gx=src_gx, gy=src_gy, offset_x=offset_x, offset_y=offset_y
|
|
774
|
+
)
|
|
775
|
+
dst_cx, dst_cy = _iso_front_anchor(
|
|
776
|
+
layout, gx=dst_gx, gy=dst_gy, offset_x=offset_x, offset_y=offset_y
|
|
777
|
+
)
|
|
778
|
+
path_cmds = _iso_edge_path(
|
|
779
|
+
layout,
|
|
780
|
+
offset_x,
|
|
781
|
+
offset_y,
|
|
782
|
+
src_gx,
|
|
783
|
+
src_gy,
|
|
784
|
+
dst_gx,
|
|
785
|
+
dst_gy,
|
|
786
|
+
src_cx,
|
|
787
|
+
src_cy,
|
|
788
|
+
dst_cx,
|
|
789
|
+
dst_cy,
|
|
790
|
+
)
|
|
691
791
|
dash = ' stroke-dasharray="8 6"' if edge.wireless else ""
|
|
692
792
|
lines.append(
|
|
693
|
-
f'<path d="{
|
|
793
|
+
f'<path d="{" ".join(path_cmds)}" stroke="{color}" stroke-width="{width_px}" '
|
|
694
794
|
f'fill="none" stroke-linecap="round" stroke-linejoin="round"{dash}/>'
|
|
695
795
|
)
|
|
696
796
|
if edge.poe:
|
|
697
797
|
icon_x = dst_cx
|
|
698
|
-
icon_y = dst_cy -
|
|
798
|
+
icon_y = dst_cy - layout.tile_height * 0.4
|
|
699
799
|
lines.append(
|
|
700
800
|
f'<text x="{icon_x}" y="{icon_y}" text-anchor="middle" fill="#1e88e5" '
|
|
701
801
|
f'font-size="{max(options.font_size, 10)}">⚡</text>'
|
|
702
802
|
)
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
803
|
+
_record_iso_edge_label(edge, node_types, node_port_labels, node_port_prefix)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _iso_edge_path(
|
|
807
|
+
layout: IsoLayout,
|
|
808
|
+
offset_x: float,
|
|
809
|
+
offset_y: float,
|
|
810
|
+
src_gx: float,
|
|
811
|
+
src_gy: float,
|
|
812
|
+
dst_gx: float,
|
|
813
|
+
dst_gy: float,
|
|
814
|
+
src_cx: float,
|
|
815
|
+
src_cy: float,
|
|
816
|
+
dst_cx: float,
|
|
817
|
+
dst_cy: float,
|
|
818
|
+
) -> list[str]:
|
|
819
|
+
dx = dst_gx - src_gx
|
|
820
|
+
dy = dst_gy - src_gy
|
|
821
|
+
if dx == 0 or dy == 0:
|
|
822
|
+
return [f"M {src_cx} {src_cy}", f"L {dst_cx} {dst_cy}"]
|
|
823
|
+
elbow_gx, elbow_gy = dst_gx, src_gy
|
|
824
|
+
elbow_cx, elbow_cy = _iso_front_anchor(
|
|
825
|
+
layout,
|
|
826
|
+
gx=elbow_gx,
|
|
827
|
+
gy=elbow_gy,
|
|
828
|
+
offset_x=offset_x,
|
|
829
|
+
offset_y=offset_y,
|
|
830
|
+
)
|
|
831
|
+
return [
|
|
832
|
+
f"M {src_cx} {src_cy}",
|
|
833
|
+
f"L {elbow_cx} {elbow_cy}",
|
|
834
|
+
f"L {dst_cx} {dst_cy}",
|
|
835
|
+
]
|
|
729
836
|
|
|
730
|
-
node_depth = 0.0
|
|
731
837
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
838
|
+
def _record_iso_edge_label(
|
|
839
|
+
edge: Edge,
|
|
840
|
+
node_types: dict[str, str],
|
|
841
|
+
node_port_labels: dict[str, str],
|
|
842
|
+
node_port_prefix: dict[str, str],
|
|
843
|
+
) -> None:
|
|
844
|
+
if not edge.label:
|
|
845
|
+
return
|
|
846
|
+
label_text = _compact_edge_label(edge.label, left_node=edge.left, right_node=edge.right)
|
|
847
|
+
left_type = node_types.get(edge.left, "other")
|
|
848
|
+
right_type = node_types.get(edge.right, "other")
|
|
849
|
+
client_node = None
|
|
850
|
+
upstream_node = None
|
|
851
|
+
if left_type == "client" and right_type != "client":
|
|
852
|
+
client_node = edge.left
|
|
853
|
+
upstream_node = edge.right
|
|
854
|
+
elif right_type == "client" and left_type != "client":
|
|
855
|
+
client_node = edge.right
|
|
856
|
+
upstream_node = edge.left
|
|
857
|
+
if client_node and upstream_node:
|
|
858
|
+
if "<->" not in label_text:
|
|
859
|
+
upstream_part = edge.label.split("<->", 1)[0].strip()
|
|
860
|
+
port_text = _extract_port_text(upstream_part) or label_text
|
|
861
|
+
node_port_labels.setdefault(client_node, f"{upstream_node}: {port_text}")
|
|
862
|
+
node_port_prefix.setdefault(client_node, _shorten_prefix(upstream_node))
|
|
863
|
+
return
|
|
864
|
+
upstream_part = edge.label.split("<->", 1)[0].strip()
|
|
865
|
+
upstream_name = _extract_device_name(upstream_part) or edge.left
|
|
866
|
+
if label_text.lower().startswith("port "):
|
|
867
|
+
label_text = f"{upstream_name} {label_text}"
|
|
868
|
+
node_port_labels.setdefault(edge.right, label_text)
|
|
869
|
+
node_port_prefix.setdefault(edge.right, _shorten_prefix(edge.left))
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _iso_node_polygons(
|
|
873
|
+
x: float,
|
|
874
|
+
y: float,
|
|
875
|
+
tile_w: float,
|
|
876
|
+
tile_h: float,
|
|
877
|
+
node_depth: float,
|
|
878
|
+
) -> tuple[list[tuple[float, float]], list[tuple[float, float]], list[tuple[float, float]]]:
|
|
879
|
+
top = [
|
|
880
|
+
(x + tile_w / 2, y),
|
|
881
|
+
(x + tile_w, y + tile_h / 2),
|
|
882
|
+
(x + tile_w / 2, y + tile_h),
|
|
883
|
+
(x, y + tile_h / 2),
|
|
884
|
+
]
|
|
885
|
+
left = [
|
|
886
|
+
(x, y + tile_h / 2),
|
|
887
|
+
(x + tile_w / 2, y + tile_h),
|
|
888
|
+
(x + tile_w / 2, y + tile_h + node_depth),
|
|
889
|
+
(x, y + tile_h / 2 + node_depth),
|
|
890
|
+
]
|
|
891
|
+
right = [
|
|
892
|
+
(x + tile_w, y + tile_h / 2),
|
|
893
|
+
(x + tile_w / 2, y + tile_h),
|
|
894
|
+
(x + tile_w / 2, y + tile_h + node_depth),
|
|
895
|
+
(x + tile_w, y + tile_h / 2 + node_depth),
|
|
896
|
+
]
|
|
897
|
+
return top, left, right
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _iso_render_faces(
|
|
901
|
+
lines: list[str],
|
|
902
|
+
*,
|
|
903
|
+
top: list[tuple[float, float]],
|
|
904
|
+
left: list[tuple[float, float]],
|
|
905
|
+
right: list[tuple[float, float]],
|
|
906
|
+
fill: str,
|
|
907
|
+
stroke: str,
|
|
908
|
+
left_fill: str,
|
|
909
|
+
right_fill: str,
|
|
910
|
+
node_depth: float,
|
|
911
|
+
) -> None:
|
|
912
|
+
if node_depth > 0:
|
|
913
|
+
lines.append(
|
|
914
|
+
f'<polygon points="{" ".join(f"{px},{py}" for px, py in left)}" '
|
|
915
|
+
f'fill="{left_fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
916
|
+
)
|
|
765
917
|
lines.append(
|
|
766
|
-
f'<polygon points="{" ".join(f"{px},{py}" for px, py in
|
|
767
|
-
f'fill="{
|
|
918
|
+
f'<polygon points="{" ".join(f"{px},{py}" for px, py in right)}" '
|
|
919
|
+
f'fill="{right_fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
768
920
|
)
|
|
921
|
+
lines.append(
|
|
922
|
+
f'<polygon points="{" ".join(f"{px},{py}" for px, py in top)}" '
|
|
923
|
+
f'fill="{fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
924
|
+
)
|
|
769
925
|
|
|
770
|
-
icon_href = icons.get(node_type, icons.get("other"))
|
|
771
|
-
center_x = x + tile_w / 2
|
|
772
|
-
center_y = y + tile_h / 2
|
|
773
|
-
icon_center_x = center_x
|
|
774
|
-
icon_center_y = center_y
|
|
775
|
-
iso_icon_size = min(tile_w, tile_h) * 1.26
|
|
776
926
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
icon_center_y -= tile_h * 0.4
|
|
851
|
-
if icon_href:
|
|
852
|
-
icon_x = icon_center_x - iso_icon_size / 2
|
|
853
|
-
icon_lift = tile_h * (0.02 if port_label else 0.04)
|
|
854
|
-
icon_y = icon_center_y - iso_icon_size / 2 - icon_lift - tile_h * 0.05
|
|
855
|
-
if node_type == "client":
|
|
856
|
-
icon_y -= tile_h * 0.05
|
|
857
|
-
lines.append(
|
|
858
|
-
f'<image href="{icon_href}" x="{icon_x}" y="{icon_y}" '
|
|
859
|
-
f'width="{iso_icon_size}" height="{iso_icon_size}" '
|
|
860
|
-
f'preserveAspectRatio="xMidYMid meet"/>'
|
|
861
|
-
)
|
|
862
|
-
|
|
863
|
-
name_font_size = max(options.font_size - 2, 8)
|
|
864
|
-
name_x, name_y, name_angle = _iso_name_label_position(
|
|
865
|
-
top,
|
|
866
|
-
tile_width=tile_w,
|
|
867
|
-
tile_height=tile_h,
|
|
868
|
-
font_size=name_font_size,
|
|
927
|
+
def _render_iso_port_label(
|
|
928
|
+
lines: list[str],
|
|
929
|
+
*,
|
|
930
|
+
port_label: str,
|
|
931
|
+
node_type: str,
|
|
932
|
+
prefix: str,
|
|
933
|
+
center_x: float,
|
|
934
|
+
center_y: float,
|
|
935
|
+
tile_w: float,
|
|
936
|
+
tile_h: float,
|
|
937
|
+
fill: str,
|
|
938
|
+
stroke: str,
|
|
939
|
+
left_fill: str,
|
|
940
|
+
right_fill: str,
|
|
941
|
+
font_size: int,
|
|
942
|
+
) -> tuple[float, float]:
|
|
943
|
+
tile_width = tile_w
|
|
944
|
+
tile_height = tile_h
|
|
945
|
+
stack_depth = tile_h / 2
|
|
946
|
+
label_center_x = center_x
|
|
947
|
+
label_center_y = center_y - stack_depth
|
|
948
|
+
top_points = _iso_tile_points(label_center_x, label_center_y, tile_width, tile_height)
|
|
949
|
+
tile_points = _points_to_svg(top_points)
|
|
950
|
+
bottom_points = [(px, py + stack_depth) for px, py in top_points]
|
|
951
|
+
right_face = [
|
|
952
|
+
top_points[1],
|
|
953
|
+
top_points[2],
|
|
954
|
+
bottom_points[2],
|
|
955
|
+
bottom_points[1],
|
|
956
|
+
]
|
|
957
|
+
left_face = [
|
|
958
|
+
top_points[3],
|
|
959
|
+
top_points[2],
|
|
960
|
+
bottom_points[2],
|
|
961
|
+
bottom_points[3],
|
|
962
|
+
]
|
|
963
|
+
left_points = " ".join(f"{px},{py}" for px, py in left_face)
|
|
964
|
+
right_points = " ".join(f"{px},{py}" for px, py in right_face)
|
|
965
|
+
lines.append(
|
|
966
|
+
f'<polygon class="label-tile-side" points="{left_points}" '
|
|
967
|
+
f'fill="{left_fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
968
|
+
)
|
|
969
|
+
lines.append(
|
|
970
|
+
f'<polygon class="label-tile-side" points="{right_points}" '
|
|
971
|
+
f'fill="{right_fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
972
|
+
)
|
|
973
|
+
lines.append(
|
|
974
|
+
f'<polygon class="label-tile" points="{tile_points}" '
|
|
975
|
+
f'fill="{fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
976
|
+
)
|
|
977
|
+
left_edge_top = top_points[0]
|
|
978
|
+
left_edge_bottom = top_points[3]
|
|
979
|
+
edge_len = math.hypot(
|
|
980
|
+
left_edge_bottom[0] - left_edge_top[0],
|
|
981
|
+
left_edge_bottom[1] - left_edge_top[1],
|
|
982
|
+
)
|
|
983
|
+
max_chars = max(6, int((edge_len * 0.85) / (font_size * 0.6)))
|
|
984
|
+
front_lines = _format_port_label_lines(
|
|
985
|
+
port_label,
|
|
986
|
+
node_type=node_type,
|
|
987
|
+
prefix=prefix,
|
|
988
|
+
max_chars=max_chars,
|
|
989
|
+
)
|
|
990
|
+
if front_lines:
|
|
991
|
+
text_x, text_y, edge_angle = _iso_front_text_position(top_points, tile_w, tile_h)
|
|
992
|
+
_render_iso_text(
|
|
993
|
+
lines,
|
|
994
|
+
text_x=text_x,
|
|
995
|
+
text_y=text_y,
|
|
996
|
+
angle=edge_angle,
|
|
997
|
+
text_lines=front_lines,
|
|
998
|
+
font_size=font_size,
|
|
999
|
+
fill="#555",
|
|
869
1000
|
)
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
1001
|
+
return label_center_x, label_center_y
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _render_iso_node(
|
|
1005
|
+
lines: list[str],
|
|
1006
|
+
*,
|
|
1007
|
+
name: str,
|
|
1008
|
+
x: float,
|
|
1009
|
+
y: float,
|
|
1010
|
+
node_type: str,
|
|
1011
|
+
icons: dict[str, str],
|
|
1012
|
+
options: SvgOptions,
|
|
1013
|
+
port_label: str | None,
|
|
1014
|
+
port_prefix: str | None,
|
|
1015
|
+
layout: IsoLayout,
|
|
1016
|
+
) -> None:
|
|
1017
|
+
fill, stroke = _TYPE_COLORS.get(node_type, _TYPE_COLORS["other"])
|
|
1018
|
+
fill = f"url(#iso-node-{node_type})"
|
|
1019
|
+
node_depth = 0.0
|
|
1020
|
+
tile_w = layout.tile_width
|
|
1021
|
+
tile_h = layout.tile_height
|
|
1022
|
+
top, left, right = _iso_node_polygons(x, y, tile_w, tile_h, node_depth)
|
|
1023
|
+
left_fill = "#d0d0d0" if node_type == "other" else "#dcdcdc"
|
|
1024
|
+
right_fill = "#c2c2c2" if node_type == "other" else "#c8c8c8"
|
|
1025
|
+
_iso_render_faces(
|
|
1026
|
+
lines,
|
|
1027
|
+
top=top,
|
|
1028
|
+
left=left,
|
|
1029
|
+
right=right,
|
|
1030
|
+
fill=fill,
|
|
1031
|
+
stroke=stroke,
|
|
1032
|
+
left_fill=left_fill,
|
|
1033
|
+
right_fill=right_fill,
|
|
1034
|
+
node_depth=node_depth,
|
|
1035
|
+
)
|
|
1036
|
+
icon_href = icons.get(node_type, icons.get("other"))
|
|
1037
|
+
center_x = x + tile_w / 2
|
|
1038
|
+
center_y = y + tile_h / 2
|
|
1039
|
+
icon_center_x = center_x
|
|
1040
|
+
icon_center_y = center_y
|
|
1041
|
+
iso_icon_size = min(tile_w, tile_h) * 1.26
|
|
1042
|
+
if port_label:
|
|
1043
|
+
font_size = max(options.font_size - 2, 8)
|
|
1044
|
+
prefix = port_prefix or "switch"
|
|
1045
|
+
icon_center_x, icon_center_y = _render_iso_port_label(
|
|
1046
|
+
lines,
|
|
1047
|
+
port_label=port_label,
|
|
1048
|
+
node_type=node_type,
|
|
1049
|
+
prefix=prefix,
|
|
1050
|
+
center_x=center_x,
|
|
1051
|
+
center_y=center_y,
|
|
1052
|
+
tile_w=tile_w,
|
|
1053
|
+
tile_h=tile_h,
|
|
1054
|
+
fill=fill,
|
|
1055
|
+
stroke=stroke,
|
|
1056
|
+
left_fill=left_fill,
|
|
1057
|
+
right_fill=right_fill,
|
|
1058
|
+
font_size=font_size,
|
|
873
1059
|
)
|
|
1060
|
+
if node_type == "ap":
|
|
1061
|
+
icon_center_y -= tile_h * 0.4
|
|
1062
|
+
if icon_href:
|
|
1063
|
+
icon_x = icon_center_x - iso_icon_size / 2
|
|
1064
|
+
icon_lift = tile_h * (0.02 if port_label else 0.04)
|
|
1065
|
+
icon_y = icon_center_y - iso_icon_size / 2 - icon_lift - tile_h * 0.05
|
|
1066
|
+
if node_type == "client":
|
|
1067
|
+
icon_y -= tile_h * 0.05
|
|
874
1068
|
lines.append(
|
|
875
|
-
f'<
|
|
876
|
-
f'
|
|
877
|
-
f"
|
|
1069
|
+
f'<image href="{icon_href}" x="{icon_x}" y="{icon_y}" '
|
|
1070
|
+
f'width="{iso_icon_size}" height="{iso_icon_size}" '
|
|
1071
|
+
f'preserveAspectRatio="xMidYMid meet"/>'
|
|
878
1072
|
)
|
|
1073
|
+
name_font_size = max(options.font_size - 2, 8)
|
|
1074
|
+
name_x, name_y, name_angle = _iso_name_label_position(
|
|
1075
|
+
top,
|
|
1076
|
+
tile_width=tile_w,
|
|
1077
|
+
tile_height=tile_h,
|
|
1078
|
+
font_size=name_font_size,
|
|
1079
|
+
)
|
|
1080
|
+
name_transform = (
|
|
1081
|
+
f"translate({name_x} {name_y}) rotate({name_angle}) skewX(30) "
|
|
1082
|
+
f"translate({-name_x} {-name_y})"
|
|
1083
|
+
)
|
|
1084
|
+
lines.append(
|
|
1085
|
+
f'<text x="{name_x}" y="{name_y}" text-anchor="middle" fill="#1f1f1f" '
|
|
1086
|
+
f'font-size="{name_font_size}" transform="{name_transform}">{_escape_text(name)}</text>'
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
def _render_iso_nodes(
|
|
1091
|
+
lines: list[str],
|
|
1092
|
+
*,
|
|
1093
|
+
positions: dict[str, tuple[float, float]],
|
|
1094
|
+
node_types: dict[str, str],
|
|
1095
|
+
icons: dict[str, str],
|
|
1096
|
+
options: SvgOptions,
|
|
1097
|
+
layout: IsoLayout,
|
|
1098
|
+
node_port_labels: dict[str, str],
|
|
1099
|
+
node_port_prefix: dict[str, str],
|
|
1100
|
+
) -> None:
|
|
1101
|
+
for name, (x, y) in positions.items():
|
|
1102
|
+
_render_iso_node(
|
|
1103
|
+
lines,
|
|
1104
|
+
name=name,
|
|
1105
|
+
x=x,
|
|
1106
|
+
y=y,
|
|
1107
|
+
node_type=node_types.get(name, "other"),
|
|
1108
|
+
icons=icons,
|
|
1109
|
+
options=options,
|
|
1110
|
+
port_label=node_port_labels.get(name),
|
|
1111
|
+
port_prefix=node_port_prefix.get(name),
|
|
1112
|
+
layout=layout,
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def render_svg_isometric(
|
|
1117
|
+
edges: list[Edge],
|
|
1118
|
+
*,
|
|
1119
|
+
node_types: dict[str, str],
|
|
1120
|
+
options: SvgOptions | None = None,
|
|
1121
|
+
theme: SvgTheme = DEFAULT_THEME,
|
|
1122
|
+
) -> str:
|
|
1123
|
+
options = options or SvgOptions()
|
|
1124
|
+
icons = _load_isometric_icons()
|
|
1125
|
+
layout_positions = _iso_layout_positions(edges, node_types, options)
|
|
1126
|
+
layout = layout_positions.layout
|
|
1127
|
+
grid_positions = layout_positions.grid_positions
|
|
1128
|
+
positions = layout_positions.positions
|
|
1129
|
+
|
|
1130
|
+
out_width = options.width or int(layout_positions.width)
|
|
1131
|
+
out_height = options.height or int(layout_positions.height)
|
|
1132
|
+
|
|
1133
|
+
lines = [
|
|
1134
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
|
|
1135
|
+
f'viewBox="0 0 {layout_positions.width} {layout_positions.height}">',
|
|
1136
|
+
svg_defs("iso", theme),
|
|
1137
|
+
(
|
|
1138
|
+
"<style>text{font-family:Arial,Helvetica,sans-serif;font-size:"
|
|
1139
|
+
f"{options.font_size}px;"
|
|
1140
|
+
"}</style>"
|
|
1141
|
+
),
|
|
1142
|
+
]
|
|
1143
|
+
|
|
1144
|
+
grid_lines = _iso_grid_lines(grid_positions, layout)
|
|
1145
|
+
if grid_lines:
|
|
1146
|
+
lines.append('<g class="iso-grid" opacity="0.7">')
|
|
1147
|
+
lines.extend(grid_lines)
|
|
1148
|
+
lines.append("</g>")
|
|
1149
|
+
|
|
1150
|
+
node_port_labels: dict[str, str] = {}
|
|
1151
|
+
node_port_prefix: dict[str, str] = {}
|
|
1152
|
+
_render_iso_edges(
|
|
1153
|
+
lines,
|
|
1154
|
+
edges,
|
|
1155
|
+
positions=positions,
|
|
1156
|
+
grid_positions=grid_positions,
|
|
1157
|
+
node_types=node_types,
|
|
1158
|
+
layout=layout,
|
|
1159
|
+
options=options,
|
|
1160
|
+
offset_x=layout_positions.offset_x,
|
|
1161
|
+
offset_y=layout_positions.offset_y,
|
|
1162
|
+
node_port_labels=node_port_labels,
|
|
1163
|
+
node_port_prefix=node_port_prefix,
|
|
1164
|
+
)
|
|
1165
|
+
_render_iso_nodes(
|
|
1166
|
+
lines,
|
|
1167
|
+
positions=positions,
|
|
1168
|
+
node_types=node_types,
|
|
1169
|
+
icons=icons,
|
|
1170
|
+
options=options,
|
|
1171
|
+
layout=layout,
|
|
1172
|
+
node_port_labels=node_port_labels,
|
|
1173
|
+
node_port_prefix=node_port_prefix,
|
|
1174
|
+
)
|
|
879
1175
|
|
|
880
1176
|
lines.append("</svg>")
|
|
881
1177
|
return "\n".join(lines) + "\n"
|