unifi-network-maps 1.4.14__py3-none-any.whl → 1.5.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.
- unifi_network_maps/__init__.py +1 -1
- unifi_network_maps/adapters/unifi.py +83 -101
- unifi_network_maps/assets/icons/modern/ap.svg +9 -0
- unifi_network_maps/assets/icons/modern/camera.svg +9 -0
- unifi_network_maps/assets/icons/modern/client.svg +9 -0
- unifi_network_maps/assets/icons/modern/game_console.svg +10 -0
- unifi_network_maps/assets/icons/modern/gateway.svg +17 -0
- unifi_network_maps/assets/icons/modern/iot.svg +9 -0
- unifi_network_maps/assets/icons/modern/nas.svg +9 -0
- unifi_network_maps/assets/icons/modern/other.svg +10 -0
- unifi_network_maps/assets/icons/modern/phone.svg +10 -0
- unifi_network_maps/assets/icons/modern/printer.svg +9 -0
- unifi_network_maps/assets/icons/modern/speaker.svg +10 -0
- unifi_network_maps/assets/icons/modern/switch.svg +10 -0
- unifi_network_maps/assets/icons/modern/tv.svg +10 -0
- unifi_network_maps/assets/icons/modern-flat/ap.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/camera.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/client.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/game_console.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/gateway.svg +13 -0
- unifi_network_maps/assets/icons/modern-flat/iot.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/nas.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/other.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/phone.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/printer.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/speaker.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/switch.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/tv.svg +6 -0
- unifi_network_maps/assets/themes/dark.yaml +53 -10
- unifi_network_maps/assets/themes/default.yaml +34 -0
- unifi_network_maps/assets/themes/minimal-dark.yaml +98 -0
- unifi_network_maps/assets/themes/minimal.yaml +92 -0
- unifi_network_maps/assets/themes/unifi-dark.yaml +97 -0
- unifi_network_maps/assets/themes/unifi.yaml +92 -0
- unifi_network_maps/cli/args.py +54 -0
- unifi_network_maps/cli/main.py +18 -7
- unifi_network_maps/cli/render.py +79 -27
- unifi_network_maps/cli/runtime.py +29 -15
- unifi_network_maps/io/debug.py +2 -1
- unifi_network_maps/io/export.py +19 -13
- unifi_network_maps/io/mock_data.py +5 -3
- unifi_network_maps/io/paths.py +5 -3
- unifi_network_maps/model/classify.py +199 -0
- unifi_network_maps/model/clients.py +271 -0
- unifi_network_maps/model/connection.py +37 -0
- unifi_network_maps/model/diff.py +544 -0
- unifi_network_maps/model/edges.py +558 -0
- unifi_network_maps/model/helpers.py +64 -0
- unifi_network_maps/model/lldp.py +20 -25
- unifi_network_maps/model/mock.py +110 -23
- unifi_network_maps/model/snapshot.py +294 -0
- unifi_network_maps/model/topology.py +143 -931
- unifi_network_maps/model/topology_coerce.py +339 -0
- unifi_network_maps/model/vlans.py +32 -46
- unifi_network_maps/model/wan.py +132 -0
- unifi_network_maps/render/device_ports_md.py +39 -97
- unifi_network_maps/render/device_summary.py +53 -0
- unifi_network_maps/render/lldp_md.py +29 -219
- unifi_network_maps/render/markdown_tables.py +8 -0
- unifi_network_maps/render/mermaid.py +11 -2
- unifi_network_maps/render/mkdocs.py +2 -1
- unifi_network_maps/render/svg.py +566 -908
- unifi_network_maps/render/svg_icons.py +231 -0
- unifi_network_maps/render/svg_isometric.py +1196 -0
- unifi_network_maps/render/svg_labels.py +184 -0
- unifi_network_maps/render/svg_theme.py +166 -32
- unifi_network_maps/render/theme.py +86 -1
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/RECORD +73 -31
- unifi_network_maps/assets/icons/isometric/printer.svg +0 -122
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.14.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
unifi_network_maps/render/svg.py
CHANGED
|
@@ -3,339 +3,94 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import base64
|
|
6
|
+
import functools
|
|
6
7
|
import math
|
|
7
8
|
from collections.abc import Callable
|
|
8
9
|
from dataclasses import dataclass
|
|
9
|
-
from html import escape as
|
|
10
|
+
from html import escape as _escape_html
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
|
-
from ..model.topology import Edge
|
|
13
|
+
from ..model.topology import Edge, WanInfo
|
|
14
|
+
from .svg_icons import (
|
|
15
|
+
_TYPE_COLORS,
|
|
16
|
+
_TYPE_ORDER,
|
|
17
|
+
_load_icons,
|
|
18
|
+
)
|
|
19
|
+
from .svg_labels import (
|
|
20
|
+
_build_wan_label_lines,
|
|
21
|
+
_compact_edge_label,
|
|
22
|
+
_escape_text,
|
|
23
|
+
_extract_device_name,
|
|
24
|
+
_extract_port_text,
|
|
25
|
+
_wrap_text,
|
|
26
|
+
)
|
|
13
27
|
from .svg_theme import DEFAULT_THEME, SvgTheme, svg_defs
|
|
14
28
|
|
|
29
|
+
_FONTS_DIR = Path(__file__).resolve().parents[1] / "assets" / "fonts"
|
|
30
|
+
_SYSTEM_FONT_STACK = "Arial,Helvetica,sans-serif"
|
|
15
31
|
|
|
16
|
-
@dataclass(frozen=True)
|
|
17
|
-
class SvgOptions:
|
|
18
|
-
node_width: int = 160
|
|
19
|
-
node_height: int = 48
|
|
20
|
-
h_gap: int = 80
|
|
21
|
-
v_gap: int = 80
|
|
22
|
-
padding: int = 40
|
|
23
|
-
font_size: int = 10
|
|
24
|
-
icon_size: int = 18
|
|
25
|
-
width: int | None = None
|
|
26
|
-
height: int | None = None
|
|
27
32
|
|
|
33
|
+
@functools.lru_cache(maxsize=4)
|
|
34
|
+
def _build_font_style(font_family: str | None) -> tuple[str, str]:
|
|
35
|
+
"""Build @font-face CSS and font-family stack for the given font.
|
|
28
36
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
step_width: float
|
|
35
|
-
step_height: float
|
|
36
|
-
grid_spacing_x: int
|
|
37
|
-
grid_spacing_y: int
|
|
38
|
-
padding: float
|
|
39
|
-
tile_y_offset: float
|
|
40
|
-
extra_pad: float
|
|
37
|
+
Results are cached to avoid repeated disk I/O for the same font family.
|
|
38
|
+
Returns (font_face_css, font_family_css) where font_face_css may be empty.
|
|
39
|
+
"""
|
|
40
|
+
if not font_family:
|
|
41
|
+
return "", _SYSTEM_FONT_STACK
|
|
41
42
|
|
|
43
|
+
slug = font_family.lower().replace(" ", "-")
|
|
44
|
+
font_face_parts: list[str] = []
|
|
42
45
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _iso_layout(options: SvgOptions) -> IsoLayout:
|
|
55
|
-
tile_width = options.node_width * 1.5
|
|
56
|
-
iso_angle = math.radians(30.0)
|
|
57
|
-
tile_height = tile_width * math.tan(iso_angle)
|
|
58
|
-
step_width = tile_width
|
|
59
|
-
step_height = tile_height
|
|
60
|
-
grid_spacing_x = max(2, 1 + int(round(options.h_gap / max(tile_width, 1))))
|
|
61
|
-
grid_spacing_y = max(2, 1 + int(round(options.v_gap / max(tile_height, 1))))
|
|
62
|
-
padding = float(options.padding)
|
|
63
|
-
tile_y_offset = tile_height / 2
|
|
64
|
-
extra_pad = max(12.0, tile_width * 0.35)
|
|
65
|
-
return IsoLayout(
|
|
66
|
-
iso_angle=iso_angle,
|
|
67
|
-
tile_width=tile_width,
|
|
68
|
-
tile_height=tile_height,
|
|
69
|
-
step_width=step_width,
|
|
70
|
-
step_height=step_height,
|
|
71
|
-
grid_spacing_x=grid_spacing_x,
|
|
72
|
-
grid_spacing_y=grid_spacing_y,
|
|
73
|
-
padding=padding,
|
|
74
|
-
tile_y_offset=tile_y_offset,
|
|
75
|
-
extra_pad=extra_pad,
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
_TYPE_ORDER = ["gateway", "switch", "ap", "client", "other"]
|
|
80
|
-
_ICON_FILES = {
|
|
81
|
-
"gateway": "router-network.svg",
|
|
82
|
-
"switch": "server-network.svg",
|
|
83
|
-
"ap": "access-point.svg",
|
|
84
|
-
"client": "laptop.svg",
|
|
85
|
-
"other": "server.svg",
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
_ISO_ICON_FILES = {
|
|
89
|
-
"gateway": "router.svg",
|
|
90
|
-
"switch": "switch-module.svg",
|
|
91
|
-
"ap": "tower.svg",
|
|
92
|
-
"client": "laptop.svg",
|
|
93
|
-
"other": "server.svg",
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
_TYPE_COLORS = {
|
|
97
|
-
"gateway": ("#ffd199", "#f08a00"),
|
|
98
|
-
"switch": ("#bfe4ff", "#1c6dd0"),
|
|
99
|
-
"ap": ("#c4f2d4", "#1f9a50"),
|
|
100
|
-
"client": ("#e4ccff", "#6b2fb4"),
|
|
101
|
-
"other": ("#e3e3e3", "#7b7b7b"),
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def _escape_text(value: str) -> str:
|
|
106
|
-
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def _extract_port_text(side: str) -> str | None:
|
|
110
|
-
candidate = side.split(":", 1)[1].strip() if ":" in side else side.strip()
|
|
111
|
-
if candidate.lower().startswith("port "):
|
|
112
|
-
return candidate
|
|
113
|
-
return None
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _extract_device_name(side: str) -> str | None:
|
|
117
|
-
if ":" not in side:
|
|
118
|
-
return None
|
|
119
|
-
name = side.split(":", 1)[0].strip()
|
|
120
|
-
return name or None
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _compact_edge_label(
|
|
124
|
-
label: str, *, left_node: str | None = None, right_node: str | None = None
|
|
125
|
-
) -> str:
|
|
126
|
-
if "<->" not in label:
|
|
127
|
-
return label
|
|
128
|
-
left_segment, right_segment = (part.strip() for part in label.split("<->", 1))
|
|
129
|
-
left_name = _extract_device_name(left_segment)
|
|
130
|
-
right_name = _extract_device_name(right_segment)
|
|
131
|
-
left_port = _extract_port_text(left_segment)
|
|
132
|
-
right_port = _extract_port_text(right_segment)
|
|
133
|
-
if left_node and right_node:
|
|
134
|
-
if right_name and right_name == left_node and left_name == right_node:
|
|
135
|
-
left_name, right_name = right_name, left_name
|
|
136
|
-
left_port, right_port = right_port, left_port
|
|
137
|
-
if left_port and right_port:
|
|
138
|
-
if left_name:
|
|
139
|
-
return f"{left_name} {left_port} <-> {right_port}"
|
|
140
|
-
return f"{left_port} <-> {right_port}"
|
|
141
|
-
if left_port:
|
|
142
|
-
return left_port
|
|
143
|
-
if right_port:
|
|
144
|
-
return right_port
|
|
145
|
-
return label
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def _iso_tile_points(
|
|
149
|
-
center_x: float, center_y: float, width: float, height: float
|
|
150
|
-
) -> list[tuple[float, float]]:
|
|
151
|
-
return [
|
|
152
|
-
(center_x, center_y - height / 2),
|
|
153
|
-
(center_x + width / 2, center_y),
|
|
154
|
-
(center_x, center_y + height / 2),
|
|
155
|
-
(center_x - width / 2, center_y),
|
|
156
|
-
]
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def _points_to_svg(points: list[tuple[float, float]]) -> str:
|
|
160
|
-
return " ".join(f"{px},{py}" for px, py in points)
|
|
46
|
+
for weight, suffix in ((400, "regular"), (600, "semibold")):
|
|
47
|
+
path = _FONTS_DIR / f"{slug}-{suffix}.woff2"
|
|
48
|
+
if not path.exists():
|
|
49
|
+
continue
|
|
50
|
+
b64 = base64.b64encode(path.read_bytes()).decode("ascii")
|
|
51
|
+
font_face_parts.append(
|
|
52
|
+
f"@font-face{{font-family:'{font_family}';font-weight:{weight};"
|
|
53
|
+
f"src:url(data:font/woff2;base64,{b64}) format('woff2');}}"
|
|
54
|
+
)
|
|
161
55
|
|
|
56
|
+
if not font_face_parts:
|
|
57
|
+
return "", _SYSTEM_FONT_STACK
|
|
162
58
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
node_type: str,
|
|
167
|
-
prefix: str,
|
|
168
|
-
max_chars: int,
|
|
169
|
-
) -> list[str]:
|
|
170
|
-
def _port_only(segment: str) -> str:
|
|
171
|
-
port = _extract_port_text(segment)
|
|
172
|
-
if port:
|
|
173
|
-
return port
|
|
174
|
-
lower = segment.lower()
|
|
175
|
-
idx = lower.rfind("port ")
|
|
176
|
-
if idx != -1:
|
|
177
|
-
return segment[idx:].strip()
|
|
178
|
-
return segment.split(":", 1)[-1].strip()
|
|
179
|
-
|
|
180
|
-
def _truncate(text: str, max_len: int = max_chars) -> str:
|
|
181
|
-
return text[: max_len - 3].rstrip() + "..." if len(text) > max_len else text
|
|
182
|
-
|
|
183
|
-
if "<->" in port_label:
|
|
184
|
-
left_part, right_part = (part.strip() for part in port_label.split("<->", 1))
|
|
185
|
-
front_text = _truncate(f"{prefix}: {_port_only(left_part)}")
|
|
186
|
-
side_prefix = prefix if node_type == "client" else "local"
|
|
187
|
-
side_text = _truncate(f"{side_prefix}: {_port_only(right_part)}")
|
|
188
|
-
return [line for line in (front_text, side_text) if line]
|
|
189
|
-
side_prefix = prefix if node_type == "client" else "local"
|
|
190
|
-
side_text = _truncate(f"{side_prefix}: {_port_only(port_label)}")
|
|
191
|
-
return [side_text]
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _iso_front_text_position(
|
|
195
|
-
top_points: list[tuple[float, float]], tile_width: float, tile_height: float
|
|
196
|
-
) -> tuple[float, float, float]:
|
|
197
|
-
left_edge_top = top_points[0]
|
|
198
|
-
left_edge_bottom = top_points[3]
|
|
199
|
-
edge_mid_x = (left_edge_top[0] + left_edge_bottom[0]) / 2
|
|
200
|
-
edge_mid_y = (left_edge_top[1] + left_edge_bottom[1]) / 2
|
|
201
|
-
center_x = sum(px for px, _py in top_points) / len(top_points)
|
|
202
|
-
center_y = sum(py for _px, py in top_points) / len(top_points)
|
|
203
|
-
normal_x = center_x - edge_mid_x
|
|
204
|
-
normal_y = center_y - edge_mid_y
|
|
205
|
-
normal_len = math.hypot(normal_x, normal_y) or 1.0
|
|
206
|
-
normal_x /= normal_len
|
|
207
|
-
normal_y /= normal_len
|
|
208
|
-
inset = tile_height * 0.27
|
|
209
|
-
text_x = edge_mid_x + normal_x * inset + tile_width * 0.02
|
|
210
|
-
text_y = edge_mid_y + normal_y * inset + tile_height * 0.33
|
|
211
|
-
edge_dx = left_edge_bottom[0] - left_edge_top[0]
|
|
212
|
-
edge_dy = left_edge_bottom[1] - left_edge_top[1]
|
|
213
|
-
edge_len = math.hypot(edge_dx, edge_dy) or 1.0
|
|
214
|
-
edge_dx /= edge_len
|
|
215
|
-
edge_dy /= edge_len
|
|
216
|
-
slide = tile_height * 0.32
|
|
217
|
-
text_x += edge_dx * slide
|
|
218
|
-
text_y += edge_dy * slide
|
|
219
|
-
name_edge_left = top_points[3]
|
|
220
|
-
name_edge_right = top_points[2]
|
|
221
|
-
angle = math.degrees(
|
|
222
|
-
math.atan2(
|
|
223
|
-
name_edge_right[1] - name_edge_left[1],
|
|
224
|
-
name_edge_right[0] - name_edge_left[0],
|
|
225
|
-
)
|
|
226
|
-
)
|
|
227
|
-
return text_x, text_y, angle
|
|
59
|
+
font_face_css = "".join(font_face_parts)
|
|
60
|
+
family_css = f"'{font_family}',{_SYSTEM_FONT_STACK}"
|
|
61
|
+
return font_face_css, family_css
|
|
228
62
|
|
|
229
63
|
|
|
230
|
-
def
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
text_y: float,
|
|
235
|
-
angle: float,
|
|
236
|
-
text_lines: list[str],
|
|
237
|
-
font_size: int,
|
|
238
|
-
fill: str,
|
|
239
|
-
) -> None:
|
|
240
|
-
line_height = font_size + 2
|
|
241
|
-
start_y = text_y - (len(text_lines) - 1) * line_height / 2
|
|
242
|
-
text_transform = (
|
|
243
|
-
f"translate({text_x} {start_y}) rotate({angle}) skewX(30) translate({-text_x} {-start_y})"
|
|
244
|
-
)
|
|
245
|
-
lines.append(
|
|
246
|
-
f'<text x="{text_x}" y="{start_y}" text-anchor="middle" fill="{fill}" '
|
|
247
|
-
f'font-size="{font_size}" font-style="normal" '
|
|
248
|
-
f'transform="{text_transform}">'
|
|
249
|
-
)
|
|
250
|
-
for idx, line in enumerate(text_lines):
|
|
251
|
-
dy = 0 if idx == 0 else line_height
|
|
252
|
-
lines.append(f'<tspan x="{text_x}" dy="{dy}">{_escape_text(line)}</tspan>')
|
|
253
|
-
lines.append("</text>")
|
|
64
|
+
def _svg_style_block(theme: SvgTheme, font_size: int, *, iso: bool = False) -> str:
|
|
65
|
+
"""Build the <style> element for an SVG, including optional @font-face."""
|
|
66
|
+
font_face, family = _build_font_style(theme.font_family)
|
|
67
|
+
parts = [f"<style>{font_face}"]
|
|
254
68
|
|
|
69
|
+
if iso:
|
|
70
|
+
parts.append(f"text{{font-family:{family};}}")
|
|
71
|
+
parts.append(f"text:not(.group-label){{font-size:{font_size}px;}}")
|
|
72
|
+
else:
|
|
73
|
+
parts.append(f"text{{font-family:{family};font-size:{font_size}px;}}")
|
|
255
74
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
tile_width: float,
|
|
260
|
-
tile_height: float,
|
|
261
|
-
font_size: int,
|
|
262
|
-
) -> tuple[float, float, float]:
|
|
263
|
-
name_edge_left = top_points[3]
|
|
264
|
-
name_edge_right = top_points[2]
|
|
265
|
-
name_mid_x = (name_edge_left[0] + name_edge_right[0]) / 2
|
|
266
|
-
name_mid_y = (name_edge_left[1] + name_edge_right[1]) / 2
|
|
267
|
-
name_center_x = sum(px for px, _py in top_points) / len(top_points)
|
|
268
|
-
name_center_y = sum(py for _px, py in top_points) / len(top_points)
|
|
269
|
-
name_normal_x = name_center_x - name_mid_x
|
|
270
|
-
name_normal_y = name_center_y - name_mid_y
|
|
271
|
-
name_normal_len = math.hypot(name_normal_x, name_normal_y) or 1.0
|
|
272
|
-
name_normal_x /= name_normal_len
|
|
273
|
-
name_normal_y /= name_normal_len
|
|
274
|
-
name_inset = tile_height * 0.13
|
|
275
|
-
name_x = name_mid_x + name_normal_x * name_inset - tile_width * 0.08
|
|
276
|
-
name_y = name_mid_y + name_normal_y * name_inset + font_size - tile_height * 0.05
|
|
277
|
-
name_angle = math.degrees(
|
|
278
|
-
math.atan2(
|
|
279
|
-
name_edge_right[1] - name_edge_left[1],
|
|
280
|
-
name_edge_right[0] - name_edge_left[0],
|
|
281
|
-
)
|
|
282
|
-
)
|
|
283
|
-
return name_x, name_y, name_angle
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def _wrap_text(label: str, *, max_len: int = 24) -> list[str]:
|
|
287
|
-
if len(label) <= max_len:
|
|
288
|
-
return [label]
|
|
289
|
-
split_at = label.rfind(" ", 0, max_len + 1)
|
|
290
|
-
if split_at == -1:
|
|
291
|
-
split_at = max_len
|
|
292
|
-
first = label[:split_at].rstrip()
|
|
293
|
-
rest = label[split_at:].lstrip()
|
|
294
|
-
return [first, rest] if rest else [first]
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
def _shorten_prefix(name: str, max_words: int = 2) -> str:
|
|
298
|
-
words = name.split()
|
|
299
|
-
if len(words) <= max_words:
|
|
300
|
-
return name
|
|
301
|
-
return " ".join(words[:max_words]) + "..."
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def _label_metrics(
|
|
305
|
-
lines: list[str], *, font_size: int, padding_x: int = 6, padding_y: int = 3
|
|
306
|
-
) -> tuple[float, float]:
|
|
307
|
-
max_len = max((len(line) for line in lines), default=0)
|
|
308
|
-
text_width = max_len * font_size * 0.6
|
|
309
|
-
text_height = len(lines) * (font_size + 2)
|
|
310
|
-
width = text_width + padding_x * 2
|
|
311
|
-
height = text_height + padding_y * 2
|
|
312
|
-
return width, height
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
def _load_icons() -> dict[str, str]:
|
|
316
|
-
base = Path(__file__).resolve().parents[1] / "assets" / "icons"
|
|
317
|
-
icons: dict[str, str] = {}
|
|
318
|
-
for node_type, filename in _ICON_FILES.items():
|
|
319
|
-
path = base / filename
|
|
320
|
-
if not path.exists():
|
|
321
|
-
continue
|
|
322
|
-
data = path.read_bytes()
|
|
323
|
-
encoded = base64.b64encode(data).decode("ascii")
|
|
324
|
-
icons[node_type] = f"data:image/svg+xml;base64,{encoded}"
|
|
325
|
-
return icons
|
|
75
|
+
parts.append("text.node-label{font-weight:600;}")
|
|
76
|
+
parts.append("</style>")
|
|
77
|
+
return "".join(parts)
|
|
326
78
|
|
|
327
79
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class SvgOptions:
|
|
82
|
+
node_width: int = 160
|
|
83
|
+
node_height: int = 48
|
|
84
|
+
h_gap: int = 80
|
|
85
|
+
v_gap: int = 80
|
|
86
|
+
padding: int = 40
|
|
87
|
+
font_size: int = 10
|
|
88
|
+
icon_size: int = 18
|
|
89
|
+
width: int | None = None
|
|
90
|
+
height: int | None = None
|
|
91
|
+
layout_mode: str = "physical" # "physical" | "grouped"
|
|
92
|
+
group_padding: int = 20
|
|
93
|
+
group_gap: int = 40
|
|
339
94
|
|
|
340
95
|
|
|
341
96
|
def _layout_nodes(
|
|
@@ -469,6 +224,285 @@ def _tree_layout_indices(
|
|
|
469
224
|
return _layout_positions(nodes, children, roots=roots, sort_key=sort_key)
|
|
470
225
|
|
|
471
226
|
|
|
227
|
+
# --- Grouped layout functions ---
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass(frozen=True)
|
|
231
|
+
class GroupBounds:
|
|
232
|
+
name: str
|
|
233
|
+
x: float
|
|
234
|
+
y: float
|
|
235
|
+
width: float
|
|
236
|
+
height: float
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _assign_nodes_to_groups(
|
|
240
|
+
nodes: set[str],
|
|
241
|
+
groups: dict[str, list[str]],
|
|
242
|
+
) -> dict[str, str]:
|
|
243
|
+
"""Map each node to its group name."""
|
|
244
|
+
node_to_group: dict[str, str] = {}
|
|
245
|
+
for group_name, members in groups.items():
|
|
246
|
+
for node in members:
|
|
247
|
+
if node in nodes:
|
|
248
|
+
node_to_group[node] = group_name
|
|
249
|
+
return node_to_group
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _resolve_group_order(
|
|
253
|
+
groups: dict[str, list[str]],
|
|
254
|
+
group_order: list[str] | None,
|
|
255
|
+
) -> list[str]:
|
|
256
|
+
"""Return ordered list of group names."""
|
|
257
|
+
if group_order:
|
|
258
|
+
return [g for g in group_order if g in groups]
|
|
259
|
+
return sorted(groups.keys())
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _filter_edges_for_group(
|
|
263
|
+
edges: list[Edge],
|
|
264
|
+
group_nodes: set[str],
|
|
265
|
+
) -> list[Edge]:
|
|
266
|
+
"""Return edges where both endpoints are in the group."""
|
|
267
|
+
return [e for e in edges if e.left in group_nodes and e.right in group_nodes]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _layout_single_group(
|
|
271
|
+
edges: list[Edge],
|
|
272
|
+
group_nodes: set[str],
|
|
273
|
+
node_types: dict[str, str],
|
|
274
|
+
options: SvgOptions,
|
|
275
|
+
) -> tuple[dict[str, tuple[float, float]], float, float]:
|
|
276
|
+
"""Layout nodes within a single group, return positions and dimensions."""
|
|
277
|
+
group_edges = _filter_edges_for_group(edges, group_nodes)
|
|
278
|
+
group_node_types = {n: node_types.get(n, "other") for n in group_nodes}
|
|
279
|
+
positions, width, height = _layout_nodes(group_edges, group_node_types, options)
|
|
280
|
+
return positions, float(width), float(height)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _compute_group_bounds(
|
|
284
|
+
group_name: str,
|
|
285
|
+
positions: dict[str, tuple[float, float]],
|
|
286
|
+
options: SvgOptions,
|
|
287
|
+
offset_x: float,
|
|
288
|
+
) -> GroupBounds:
|
|
289
|
+
"""Compute bounding rectangle for a group."""
|
|
290
|
+
if not positions:
|
|
291
|
+
return GroupBounds(group_name, offset_x, 0, 100, 100)
|
|
292
|
+
xs = [x for x, _ in positions.values()]
|
|
293
|
+
ys = [y for _, y in positions.values()]
|
|
294
|
+
min_x = min(xs) - options.group_padding
|
|
295
|
+
min_y = min(ys) - options.group_padding
|
|
296
|
+
max_x = max(xs) + options.node_width + options.group_padding
|
|
297
|
+
max_y = max(ys) + options.node_height + options.group_padding
|
|
298
|
+
return GroupBounds(group_name, min_x, min_y, max_x - min_x, max_y - min_y)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _offset_positions(
|
|
302
|
+
positions: dict[str, tuple[float, float]],
|
|
303
|
+
dx: float,
|
|
304
|
+
dy: float,
|
|
305
|
+
) -> dict[str, tuple[float, float]]:
|
|
306
|
+
"""Shift all positions by (dx, dy)."""
|
|
307
|
+
return {name: (x + dx, y + dy) for name, (x, y) in positions.items()}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _layout_grouped_nodes(
|
|
311
|
+
edges: list[Edge],
|
|
312
|
+
node_types: dict[str, str],
|
|
313
|
+
options: SvgOptions,
|
|
314
|
+
groups: dict[str, list[str]],
|
|
315
|
+
group_order: list[str] | None,
|
|
316
|
+
) -> tuple[dict[str, tuple[float, float]], list[GroupBounds], int, int]:
|
|
317
|
+
"""Layout nodes in horizontal group lanes."""
|
|
318
|
+
all_nodes = _layout_nodeset(edges, node_types)
|
|
319
|
+
ordered_groups = _resolve_group_order(groups, group_order)
|
|
320
|
+
node_to_group = _assign_nodes_to_groups(all_nodes, groups)
|
|
321
|
+
|
|
322
|
+
all_positions: dict[str, tuple[float, float]] = {}
|
|
323
|
+
group_bounds_list: list[GroupBounds] = []
|
|
324
|
+
current_x = float(options.padding)
|
|
325
|
+
max_height = 0.0
|
|
326
|
+
|
|
327
|
+
for group_name in ordered_groups:
|
|
328
|
+
group_nodes = set(groups.get(group_name, []))
|
|
329
|
+
group_nodes = group_nodes & all_nodes
|
|
330
|
+
if not group_nodes:
|
|
331
|
+
continue
|
|
332
|
+
positions, width, height = _layout_single_group(edges, group_nodes, node_types, options)
|
|
333
|
+
offset_x = current_x - options.padding
|
|
334
|
+
offset_positions = _offset_positions(positions, offset_x, 0)
|
|
335
|
+
all_positions.update(offset_positions)
|
|
336
|
+
bounds = _compute_group_bounds(group_name, offset_positions, options, current_x)
|
|
337
|
+
group_bounds_list.append(bounds)
|
|
338
|
+
current_x += width + options.group_gap
|
|
339
|
+
max_height = max(max_height, height)
|
|
340
|
+
|
|
341
|
+
ungrouped = all_nodes - set(node_to_group.keys())
|
|
342
|
+
if ungrouped:
|
|
343
|
+
ungrouped_positions, ug_width, ug_height = _layout_single_group(
|
|
344
|
+
edges, ungrouped, node_types, options
|
|
345
|
+
)
|
|
346
|
+
offset_positions = _offset_positions(ungrouped_positions, current_x - options.padding, 0)
|
|
347
|
+
all_positions.update(offset_positions)
|
|
348
|
+
bounds = _compute_group_bounds("Other", offset_positions, options, current_x)
|
|
349
|
+
group_bounds_list.append(bounds)
|
|
350
|
+
current_x += ug_width + options.group_gap
|
|
351
|
+
max_height = max(max_height, ug_height)
|
|
352
|
+
|
|
353
|
+
total_width = int(current_x - options.group_gap + options.padding)
|
|
354
|
+
total_height = int(max_height)
|
|
355
|
+
return all_positions, group_bounds_list, total_width, total_height
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _render_group_boundaries(
|
|
359
|
+
lines: list[str],
|
|
360
|
+
group_bounds_list: list[GroupBounds],
|
|
361
|
+
theme: SvgTheme,
|
|
362
|
+
options: SvgOptions,
|
|
363
|
+
) -> None:
|
|
364
|
+
"""Render group background rectangles and labels."""
|
|
365
|
+
label_size = options.font_size + 4
|
|
366
|
+
for bounds in group_bounds_list:
|
|
367
|
+
group_attr = _escape_html(bounds.name, quote=True)
|
|
368
|
+
fill, stroke = theme.group_colors(bounds.name)
|
|
369
|
+
lines.append(f'<g class="network-group" data-group-name="{group_attr}">')
|
|
370
|
+
lines.append(
|
|
371
|
+
f'<rect class="group-boundary" x="{bounds.x}" y="{bounds.y}" '
|
|
372
|
+
f'width="{bounds.width}" height="{bounds.height}" '
|
|
373
|
+
f'rx="{theme.group_radius}" fill="{fill}" fill-opacity="0.3" '
|
|
374
|
+
f'stroke="{stroke}" stroke-width="{theme.group_stroke_width}"/>'
|
|
375
|
+
)
|
|
376
|
+
label_x = bounds.x + 10
|
|
377
|
+
label_y = bounds.y + label_size + 2
|
|
378
|
+
lines.append(
|
|
379
|
+
f'<text class="group-label" x="{label_x}" y="{label_y}" '
|
|
380
|
+
f'fill="{stroke}" font-size="{label_size}" font-weight="bold">'
|
|
381
|
+
f"{_escape_text(bounds.name.capitalize())}</text>"
|
|
382
|
+
)
|
|
383
|
+
lines.append("</g>")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _render_wan_upstream(
|
|
387
|
+
lines: list[str],
|
|
388
|
+
wan_info: WanInfo,
|
|
389
|
+
gateway_position: tuple[float, float],
|
|
390
|
+
options: SvgOptions,
|
|
391
|
+
theme: SvgTheme,
|
|
392
|
+
) -> None:
|
|
393
|
+
"""Render WAN upstream visualization (orthogonal view)."""
|
|
394
|
+
gx, gy = gateway_position
|
|
395
|
+
font_size = options.font_size
|
|
396
|
+
|
|
397
|
+
# Build label lines to calculate box size
|
|
398
|
+
label_lines = _build_wan_label_lines(wan_info)
|
|
399
|
+
|
|
400
|
+
# Calculate box dimensions based on content
|
|
401
|
+
globe_size = 36
|
|
402
|
+
padding = 10
|
|
403
|
+
line_height = font_size + 4
|
|
404
|
+
max_text_width = max((len(line) for line in label_lines), default=10) * font_size * 0.55
|
|
405
|
+
box_width = max(globe_size + padding * 2, max_text_width + padding * 2)
|
|
406
|
+
box_height = globe_size + len(label_lines) * line_height + padding * 3
|
|
407
|
+
|
|
408
|
+
# Position box above the gateway (free-standing)
|
|
409
|
+
box_x = gx + options.node_width / 2 - box_width / 2
|
|
410
|
+
box_y = gy - box_height - 30
|
|
411
|
+
|
|
412
|
+
# Connection points
|
|
413
|
+
gateway_connect_x = gx + options.node_width / 2
|
|
414
|
+
gateway_connect_y = gy
|
|
415
|
+
box_connect_x = box_x + box_width / 2
|
|
416
|
+
box_connect_y = box_y + box_height
|
|
417
|
+
|
|
418
|
+
lines.append('<g class="wan-upstream">')
|
|
419
|
+
|
|
420
|
+
# Draw connector line from gateway to box
|
|
421
|
+
lines.append(
|
|
422
|
+
f'<path d="M {gateway_connect_x} {gateway_connect_y} '
|
|
423
|
+
f'L {box_connect_x} {box_connect_y}" '
|
|
424
|
+
f'stroke="#0288d1" stroke-width="2" fill="none" '
|
|
425
|
+
f'stroke-linecap="round" opacity="0.8"/>'
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
# Draw bounding box with rounded corners
|
|
429
|
+
lines.append(
|
|
430
|
+
f'<rect x="{box_x}" y="{box_y}" width="{box_width}" height="{box_height}" '
|
|
431
|
+
f'rx="6" ry="6" fill="{theme.wan_background}" stroke="{theme.wan_globe[1]}" stroke-width="1.5"/>'
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# Draw globe icon inline with gradient fill
|
|
435
|
+
globe_cx = box_x + box_width / 2
|
|
436
|
+
globe_cy = box_y + padding + globe_size / 2
|
|
437
|
+
globe_r = globe_size / 2 - 2
|
|
438
|
+
lines.append(f'<g transform="translate({globe_cx}, {globe_cy})">')
|
|
439
|
+
lines.append(
|
|
440
|
+
f'<circle cx="0" cy="0" r="{globe_r}" fill="none" stroke="url(#globe)" stroke-width="1.5"/>'
|
|
441
|
+
)
|
|
442
|
+
lines.append(
|
|
443
|
+
f'<ellipse cx="0" cy="0" rx="{globe_r * 0.35}" ry="{globe_r}" '
|
|
444
|
+
f'fill="none" stroke="url(#globe)" stroke-width="1.2"/>'
|
|
445
|
+
)
|
|
446
|
+
lines.append(
|
|
447
|
+
f'<line x1="{-globe_r}" y1="0" x2="{globe_r}" y2="0" '
|
|
448
|
+
f'stroke="url(#globe)" stroke-width="1.2"/>'
|
|
449
|
+
)
|
|
450
|
+
lines.append(
|
|
451
|
+
f'<ellipse cx="0" cy="{-globe_r * 0.5}" rx="{globe_r * 0.87}" ry="{globe_r * 0.18}" '
|
|
452
|
+
f'fill="none" stroke="url(#globe)" stroke-width="0.8"/>'
|
|
453
|
+
)
|
|
454
|
+
lines.append(
|
|
455
|
+
f'<ellipse cx="0" cy="{globe_r * 0.5}" rx="{globe_r * 0.87}" ry="{globe_r * 0.18}" '
|
|
456
|
+
f'fill="none" stroke="url(#globe)" stroke-width="0.8"/>'
|
|
457
|
+
)
|
|
458
|
+
lines.append("</g>")
|
|
459
|
+
|
|
460
|
+
# Render label lines
|
|
461
|
+
text_x = box_x + box_width / 2
|
|
462
|
+
text_y = box_y + padding + globe_size + padding + font_size
|
|
463
|
+
for i, label_text in enumerate(label_lines):
|
|
464
|
+
y = text_y + i * line_height
|
|
465
|
+
lines.append(
|
|
466
|
+
f'<text x="{text_x}" y="{y}" text-anchor="middle" '
|
|
467
|
+
f'fill="{theme.text_primary}" font-size="{font_size}">'
|
|
468
|
+
f"{_escape_text(label_text)}</text>"
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
lines.append("</g>")
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _apply_wan_offset(
|
|
475
|
+
positions: dict[str, tuple[float, float]],
|
|
476
|
+
group_bounds_list: list[GroupBounds],
|
|
477
|
+
height: float,
|
|
478
|
+
wan_offset_y: float,
|
|
479
|
+
) -> tuple[dict[str, tuple[float, float]], list[GroupBounds], float]:
|
|
480
|
+
"""Shift positions and group bounds down to make room for WAN box."""
|
|
481
|
+
shifted_positions = {name: (x, y + wan_offset_y) for name, (x, y) in positions.items()}
|
|
482
|
+
shifted_bounds = [
|
|
483
|
+
GroupBounds(
|
|
484
|
+
name=gb.name,
|
|
485
|
+
x=gb.x,
|
|
486
|
+
y=gb.y + wan_offset_y,
|
|
487
|
+
width=gb.width,
|
|
488
|
+
height=gb.height,
|
|
489
|
+
)
|
|
490
|
+
for gb in group_bounds_list
|
|
491
|
+
]
|
|
492
|
+
return shifted_positions, shifted_bounds, height + wan_offset_y
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _find_gateway_position(
|
|
496
|
+
node_types: dict[str, str],
|
|
497
|
+
positions: dict[str, tuple[float, float]],
|
|
498
|
+
) -> tuple[float, float] | None:
|
|
499
|
+
"""Find the position of the gateway node."""
|
|
500
|
+
for name, ntype in node_types.items():
|
|
501
|
+
if ntype == "gateway" and name in positions:
|
|
502
|
+
return positions[name]
|
|
503
|
+
return None
|
|
504
|
+
|
|
505
|
+
|
|
472
506
|
def render_svg(
|
|
473
507
|
edges: list[Edge],
|
|
474
508
|
*,
|
|
@@ -476,10 +510,28 @@ def render_svg(
|
|
|
476
510
|
node_data: dict[str, dict[str, str]] | None = None,
|
|
477
511
|
options: SvgOptions | None = None,
|
|
478
512
|
theme: SvgTheme = DEFAULT_THEME,
|
|
513
|
+
groups: dict[str, list[str]] | None = None,
|
|
514
|
+
group_order: list[str] | None = None,
|
|
515
|
+
wan_info: WanInfo | None = None,
|
|
479
516
|
) -> str:
|
|
480
517
|
options = options or SvgOptions()
|
|
481
|
-
icons = _load_icons()
|
|
482
|
-
|
|
518
|
+
icons = _load_icons(theme.icon_set, decal_color=theme.text_primary)
|
|
519
|
+
|
|
520
|
+
use_grouped = options.layout_mode == "grouped" and groups
|
|
521
|
+
group_bounds_list: list[GroupBounds] = []
|
|
522
|
+
if use_grouped and groups:
|
|
523
|
+
positions, group_bounds_list, width, height = _layout_grouped_nodes(
|
|
524
|
+
edges, node_types, options, groups, group_order
|
|
525
|
+
)
|
|
526
|
+
else:
|
|
527
|
+
positions, width, height = _layout_nodes(edges, node_types, options)
|
|
528
|
+
|
|
529
|
+
if wan_info:
|
|
530
|
+
wan_box_height = 36 + 3 * (options.font_size + 4) + 30 + 30
|
|
531
|
+
positions, group_bounds_list, height = _apply_wan_offset(
|
|
532
|
+
positions, group_bounds_list, height, wan_box_height
|
|
533
|
+
)
|
|
534
|
+
|
|
483
535
|
out_width = options.width or width
|
|
484
536
|
out_height = options.height or height
|
|
485
537
|
|
|
@@ -487,37 +539,180 @@ def render_svg(
|
|
|
487
539
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
|
|
488
540
|
f'viewBox="0 0 {width} {height}">',
|
|
489
541
|
svg_defs("", theme),
|
|
490
|
-
(
|
|
491
|
-
|
|
492
|
-
f"{options.font_size}px;"
|
|
493
|
-
"}</style>"
|
|
494
|
-
),
|
|
542
|
+
_svg_style_block(theme, options.font_size),
|
|
543
|
+
f'<rect width="100%" height="100%" fill="{theme.background}"/>',
|
|
495
544
|
]
|
|
496
545
|
|
|
497
|
-
|
|
498
|
-
lines,
|
|
499
|
-
|
|
546
|
+
if use_grouped and group_bounds_list:
|
|
547
|
+
_render_group_boundaries(lines, group_bounds_list, theme, options)
|
|
548
|
+
|
|
549
|
+
node_port_labels, _ = _render_svg_edges(lines, edges, positions, node_types, options, theme)
|
|
500
550
|
_render_svg_nodes(
|
|
501
551
|
lines,
|
|
502
552
|
positions,
|
|
503
553
|
node_types,
|
|
504
554
|
node_port_labels,
|
|
505
|
-
node_port_prefix,
|
|
506
555
|
icons,
|
|
507
556
|
options,
|
|
508
557
|
node_data,
|
|
558
|
+
theme,
|
|
559
|
+
groups=groups,
|
|
509
560
|
)
|
|
510
561
|
|
|
562
|
+
if wan_info:
|
|
563
|
+
gateway_pos = _find_gateway_position(node_types, positions)
|
|
564
|
+
if gateway_pos:
|
|
565
|
+
_render_wan_upstream(lines, wan_info, gateway_pos, options, theme)
|
|
566
|
+
|
|
511
567
|
lines.append("</svg>")
|
|
512
568
|
return "\n".join(lines) + "\n"
|
|
513
569
|
|
|
514
570
|
|
|
571
|
+
def _vlan_data_attrs(edge: Edge) -> str:
|
|
572
|
+
"""Generate VLAN data attributes for an edge."""
|
|
573
|
+
attrs = []
|
|
574
|
+
if edge.vlans:
|
|
575
|
+
attrs.append(f'data-vlans="{",".join(str(v) for v in edge.vlans)}"')
|
|
576
|
+
if edge.active_vlans:
|
|
577
|
+
attrs.append(f'data-active-vlans="{",".join(str(v) for v in edge.active_vlans)}"')
|
|
578
|
+
if edge.is_trunk:
|
|
579
|
+
attrs.append('data-trunk="true"')
|
|
580
|
+
return " ".join(attrs)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _edge_opacity(node_types: dict[str, str], edge: Edge) -> float:
|
|
584
|
+
"""Return opacity for edge based on endpoint types.
|
|
585
|
+
|
|
586
|
+
Client edges are semi-transparent to reduce visual clutter
|
|
587
|
+
and keep focus on infrastructure connections.
|
|
588
|
+
"""
|
|
589
|
+
left_type = node_types.get(edge.left, "other")
|
|
590
|
+
right_type = node_types.get(edge.right, "other")
|
|
591
|
+
|
|
592
|
+
if right_type == "client" or left_type == "client":
|
|
593
|
+
return 0.5
|
|
594
|
+
|
|
595
|
+
return 1.0
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _render_vlan_endpoint_markers(
|
|
599
|
+
lines: list[str],
|
|
600
|
+
x: float,
|
|
601
|
+
y: float,
|
|
602
|
+
vlans: tuple[int, ...],
|
|
603
|
+
theme: SvgTheme,
|
|
604
|
+
marker_size: int = 6,
|
|
605
|
+
max_markers: int = 4,
|
|
606
|
+
) -> None:
|
|
607
|
+
"""Render small colored squares showing active VLANs at an endpoint."""
|
|
608
|
+
if not vlans:
|
|
609
|
+
return
|
|
610
|
+
for i, vlan_id in enumerate(vlans[:max_markers]):
|
|
611
|
+
color = theme.vlan_color(vlan_id)
|
|
612
|
+
marker_x = x - marker_size - 2
|
|
613
|
+
marker_y = y + (i * (marker_size + 2))
|
|
614
|
+
lines.append(
|
|
615
|
+
f'<rect x="{marker_x}" y="{marker_y}" width="{marker_size}" '
|
|
616
|
+
f'height="{marker_size}" fill="{color}" stroke="#fff" '
|
|
617
|
+
f'stroke-width="0.5" rx="1" data-vlan="{vlan_id}">'
|
|
618
|
+
f"<title>VLAN {vlan_id}</title></rect>"
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _render_vlan_striped_edge(
|
|
623
|
+
lines: list[str],
|
|
624
|
+
path: str,
|
|
625
|
+
vlans: tuple[int, ...],
|
|
626
|
+
theme: SvgTheme,
|
|
627
|
+
base_width: int,
|
|
628
|
+
is_wireless: bool,
|
|
629
|
+
extra_attrs: str,
|
|
630
|
+
opacity: float = 1.0,
|
|
631
|
+
) -> None:
|
|
632
|
+
"""Render an edge with striped VLAN colors and glow effect."""
|
|
633
|
+
if not vlans:
|
|
634
|
+
return
|
|
635
|
+
num_vlans = len(vlans)
|
|
636
|
+
segment_len = 12 # Length of each colored segment
|
|
637
|
+
total_pattern = segment_len * num_vlans
|
|
638
|
+
gap_len = total_pattern - segment_len # Gap is rest of pattern
|
|
639
|
+
opacity_attr = f' opacity="{opacity}"' if opacity < 1.0 else ""
|
|
640
|
+
|
|
641
|
+
# Render glow layer behind the edge
|
|
642
|
+
glow_color = theme.vlan_color(vlans[0])
|
|
643
|
+
glow_width = base_width * 3
|
|
644
|
+
glow_opacity = 0.25 * opacity # Scale glow with edge opacity
|
|
645
|
+
lines.append(
|
|
646
|
+
f'<path d="{path}" stroke="{glow_color}" stroke-width="{glow_width}" '
|
|
647
|
+
f'fill="none" opacity="{glow_opacity}" filter="url(#edge-glow)" {extra_attrs}/>'
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
for i, vlan_id in enumerate(vlans):
|
|
651
|
+
color = theme.vlan_color(vlan_id)
|
|
652
|
+
offset = -i * segment_len
|
|
653
|
+
dash = f'stroke-dasharray="{segment_len} {gap_len}"'
|
|
654
|
+
if is_wireless:
|
|
655
|
+
# For wireless, use smaller dashes within the segment
|
|
656
|
+
dash = f'stroke-dasharray="4 2 4 {gap_len + 2}"'
|
|
657
|
+
lines.append(
|
|
658
|
+
f'<path d="{path}" stroke="{color}" stroke-width="{base_width}" '
|
|
659
|
+
f'fill="none" {dash} stroke-dashoffset="{offset}"{opacity_attr} {extra_attrs}/>'
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _compute_elbow_path(
|
|
664
|
+
src_cx: float, src_bottom: float, dst_cx: float, dst_top: float, mid_y: float
|
|
665
|
+
) -> str:
|
|
666
|
+
"""Compute SVG path for an elbow connector between two nodes."""
|
|
667
|
+
if math.isclose(src_cx, dst_cx, abs_tol=0.01):
|
|
668
|
+
elbow_x = src_cx + 0.5
|
|
669
|
+
return (
|
|
670
|
+
f"M {src_cx} {src_bottom} L {src_cx} {mid_y} "
|
|
671
|
+
f"L {elbow_x} {mid_y} L {dst_cx} {mid_y} L {dst_cx} {dst_top}"
|
|
672
|
+
)
|
|
673
|
+
return f"M {src_cx} {src_bottom} L {src_cx} {mid_y} L {dst_cx} {mid_y} L {dst_cx} {dst_top}"
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _render_poe_icon(
|
|
677
|
+
lines: list[str], dst_cx: float, mid_y: float, dst_top: float, theme: SvgTheme
|
|
678
|
+
) -> None:
|
|
679
|
+
"""Render PoE lightning bolt icon on an edge."""
|
|
680
|
+
poe_size = 16
|
|
681
|
+
icon_x = dst_cx - poe_size / 2
|
|
682
|
+
icon_center_y = mid_y + 0.8 * (dst_top - mid_y)
|
|
683
|
+
icon_y = icon_center_y - poe_size / 2
|
|
684
|
+
lines.append(
|
|
685
|
+
f'<use href="#poe-bolt" x="{icon_x}" y="{icon_y}" '
|
|
686
|
+
f'width="{poe_size}" height="{poe_size}" '
|
|
687
|
+
f'fill="{theme.poe_fill}" stroke="{theme.poe_stroke}" stroke-width="0.5"/>'
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _render_standard_edge(
|
|
692
|
+
lines: list[str],
|
|
693
|
+
path: str,
|
|
694
|
+
edge: Edge,
|
|
695
|
+
opacity_attr: str,
|
|
696
|
+
base_attrs: str,
|
|
697
|
+
) -> None:
|
|
698
|
+
"""Render a standard edge (no VLAN coloring)."""
|
|
699
|
+
color = "url(#link-poe)" if edge.poe else "url(#link-standard)"
|
|
700
|
+
dash = ' stroke-dasharray="6 4"' if edge.wireless else ""
|
|
701
|
+
width_px = 2 if edge.poe else 1
|
|
702
|
+
lines.append(
|
|
703
|
+
f'<path d="{path}" stroke="{color}" stroke-width="{width_px}" '
|
|
704
|
+
f'fill="none"{dash}{opacity_attr} {base_attrs}/>'
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
|
|
515
708
|
def _render_svg_edges(
|
|
516
709
|
lines: list[str],
|
|
517
710
|
edges: list[Edge],
|
|
518
711
|
positions: dict[str, tuple[float, float]],
|
|
519
712
|
node_types: dict[str, str],
|
|
520
713
|
options: SvgOptions,
|
|
714
|
+
theme: SvgTheme,
|
|
715
|
+
max_vlan_colors: int | None = None,
|
|
521
716
|
) -> tuple[dict[str, str], dict[str, str]]:
|
|
522
717
|
node_port_labels: dict[str, str] = {}
|
|
523
718
|
node_port_prefix: dict[str, str] = {}
|
|
@@ -533,33 +728,35 @@ def _render_svg_edges(
|
|
|
533
728
|
src_bottom = src_y + options.node_height
|
|
534
729
|
dst_top = dst_y
|
|
535
730
|
mid_y = (src_bottom + dst_top) / 2
|
|
536
|
-
color = "url(#link-poe)" if edge.poe else "url(#link-standard)"
|
|
537
731
|
width_px = 2 if edge.poe else 1
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
732
|
+
|
|
733
|
+
path = _compute_elbow_path(src_cx, src_bottom, dst_cx, dst_top, mid_y)
|
|
734
|
+
left_attr = _escape_html(edge.left, quote=True)
|
|
735
|
+
right_attr = _escape_html(edge.right, quote=True)
|
|
736
|
+
vlan_attrs = _vlan_data_attrs(edge)
|
|
737
|
+
base_attrs = f'data-edge-left="{left_attr}" data-edge-right="{right_attr}"'
|
|
738
|
+
if vlan_attrs:
|
|
739
|
+
base_attrs = f"{base_attrs} {vlan_attrs}"
|
|
740
|
+
|
|
741
|
+
# Determine VLANs to visualize (active only, with optional limit)
|
|
742
|
+
display_vlans = edge.active_vlans
|
|
743
|
+
if max_vlan_colors and len(display_vlans) > max_vlan_colors:
|
|
744
|
+
display_vlans = display_vlans[:max_vlan_colors]
|
|
745
|
+
|
|
746
|
+
# Client edges are semi-transparent to reduce visual clutter
|
|
747
|
+
opacity = _edge_opacity(node_types, edge)
|
|
748
|
+
opacity_attr = f' opacity="{opacity}"' if opacity < 1.0 else ""
|
|
749
|
+
|
|
750
|
+
if display_vlans:
|
|
751
|
+
_render_vlan_striped_edge(
|
|
752
|
+
lines, path, display_vlans, theme, width_px, edge.wireless, base_attrs, opacity
|
|
543
753
|
)
|
|
754
|
+
_render_vlan_endpoint_markers(lines, dst_cx, dst_top + 4, display_vlans, theme)
|
|
544
755
|
else:
|
|
545
|
-
path
|
|
546
|
-
|
|
547
|
-
f"L {dst_cx} {mid_y} L {dst_cx} {dst_top}"
|
|
548
|
-
)
|
|
549
|
-
dash = ' stroke-dasharray="6 4"' if edge.wireless else ""
|
|
550
|
-
left_attr = _escape_attr(edge.left, quote=True)
|
|
551
|
-
right_attr = _escape_attr(edge.right, quote=True)
|
|
552
|
-
lines.append(
|
|
553
|
-
f'<path d="{path}" stroke="{color}" stroke-width="{width_px}" fill="none"{dash} '
|
|
554
|
-
f'data-edge-left="{left_attr}" data-edge-right="{right_attr}"/>'
|
|
555
|
-
)
|
|
756
|
+
_render_standard_edge(lines, path, edge, opacity_attr, base_attrs)
|
|
757
|
+
|
|
556
758
|
if edge.poe:
|
|
557
|
-
|
|
558
|
-
icon_y = dst_top - 6
|
|
559
|
-
lines.append(
|
|
560
|
-
f'<text x="{icon_x}" y="{icon_y}" text-anchor="middle" fill="#1e88e5" '
|
|
561
|
-
f'font-size="{max(options.font_size, 10)}">⚡</text>'
|
|
562
|
-
)
|
|
759
|
+
_render_poe_icon(lines, dst_cx, mid_y, dst_top, theme)
|
|
563
760
|
return node_port_labels, node_port_prefix
|
|
564
761
|
|
|
565
762
|
|
|
@@ -603,16 +800,20 @@ def _render_svg_nodes(
|
|
|
603
800
|
positions: dict[str, tuple[float, float]],
|
|
604
801
|
node_types: dict[str, str],
|
|
605
802
|
node_port_labels: dict[str, str],
|
|
606
|
-
node_port_prefix: dict[str, str],
|
|
607
803
|
icons: dict[str, str],
|
|
608
804
|
options: SvgOptions,
|
|
609
805
|
node_data: dict[str, dict[str, str]] | None,
|
|
806
|
+
theme: SvgTheme,
|
|
807
|
+
*,
|
|
808
|
+
groups: dict[str, list[str]] | None = None,
|
|
610
809
|
) -> None:
|
|
810
|
+
node_to_group = _build_node_to_group_map(groups) if groups else {}
|
|
611
811
|
for name, (x, y) in positions.items():
|
|
612
812
|
node_type = node_types.get(name, "other")
|
|
613
813
|
fill, stroke = _TYPE_COLORS.get(node_type, _TYPE_COLORS["other"])
|
|
614
814
|
fill = f"url(#node-{node_type})"
|
|
615
|
-
|
|
815
|
+
group_name = node_to_group.get(name)
|
|
816
|
+
group_attrs = _svg_node_group_attrs(node_data, name, node_type, group_name)
|
|
616
817
|
lines.append(f"<g{group_attrs}>")
|
|
617
818
|
lines.append(f"<title>{_escape_text(name)}</title>")
|
|
618
819
|
lines.append(
|
|
@@ -647,589 +848,46 @@ def _render_svg_nodes(
|
|
|
647
848
|
wrapped = _wrap_text(port_label)
|
|
648
849
|
lines.append(
|
|
649
850
|
f'<text x="{text_x}" y="{port_y}" class="node-port" '
|
|
650
|
-
f'text-anchor="start" fill="
|
|
851
|
+
f'text-anchor="start" fill="{theme.text_secondary}" font-size="{font_size}">'
|
|
651
852
|
)
|
|
652
853
|
for idx, line in enumerate(wrapped):
|
|
653
854
|
dy = 0 if idx == 0 else line_height
|
|
654
855
|
lines.append(f'<tspan x="{text_x}" dy="{dy}">{_escape_text(line)}</tspan>')
|
|
655
856
|
lines.append("</text>")
|
|
656
857
|
lines.append(
|
|
657
|
-
f'<text x="{text_x}" y="{text_y}"
|
|
858
|
+
f'<text x="{text_x}" y="{text_y}" class="node-label" fill="{theme.text_primary}" '
|
|
859
|
+
f'text-anchor="start">{safe_name}</text>'
|
|
658
860
|
)
|
|
659
861
|
lines.append("</g>")
|
|
660
862
|
|
|
661
863
|
|
|
864
|
+
def _build_node_to_group_map(groups: dict[str, list[str]]) -> dict[str, str]:
|
|
865
|
+
"""Build reverse mapping from node to group name."""
|
|
866
|
+
result: dict[str, str] = {}
|
|
867
|
+
for group_name, members in groups.items():
|
|
868
|
+
for node in members:
|
|
869
|
+
result[node] = group_name
|
|
870
|
+
return result
|
|
871
|
+
|
|
872
|
+
|
|
662
873
|
def _svg_node_group_attrs(
|
|
663
874
|
node_data: dict[str, dict[str, str]] | None,
|
|
664
875
|
name: str,
|
|
665
876
|
node_type: str,
|
|
877
|
+
group_name: str | None = None,
|
|
666
878
|
) -> str:
|
|
667
879
|
attrs: dict[str, str] = {
|
|
668
880
|
"class": "unm-node",
|
|
669
881
|
"data-node-id": name,
|
|
670
882
|
"data-node-type": node_type,
|
|
671
883
|
}
|
|
884
|
+
if group_name:
|
|
885
|
+
attrs["data-group"] = group_name
|
|
672
886
|
if node_data and (extra := node_data.get(name)):
|
|
673
887
|
for key, value in extra.items():
|
|
674
888
|
if key == "class":
|
|
675
889
|
attrs["class"] = f"{attrs['class']} {value}".strip()
|
|
676
890
|
else:
|
|
677
891
|
attrs[key] = value
|
|
678
|
-
rendered = [f' {key}="{
|
|
892
|
+
rendered = [f' {key}="{_escape_html(value, quote=True)}"' for key, value in attrs.items()]
|
|
679
893
|
return "".join(rendered)
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
def _iso_project(layout: IsoLayout, gx: float, gy: float) -> tuple[float, float]:
|
|
683
|
-
iso_x = (gx - gy) * (layout.step_width / 2)
|
|
684
|
-
iso_y = (gx + gy) * (layout.step_height / 2)
|
|
685
|
-
return iso_x, iso_y
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
def _iso_project_center(layout: IsoLayout, gx: float, gy: float) -> tuple[float, float]:
|
|
689
|
-
return _iso_project(layout, gx + 0.5, gy + 0.5)
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
def _iso_layout_positions(
|
|
693
|
-
edges: list[Edge],
|
|
694
|
-
node_types: dict[str, str],
|
|
695
|
-
options: SvgOptions,
|
|
696
|
-
) -> IsoLayoutPositions:
|
|
697
|
-
layout = _iso_layout(options)
|
|
698
|
-
positions_index, levels = _tree_layout_indices(edges, node_types)
|
|
699
|
-
grid_positions: dict[str, tuple[float, float]] = {}
|
|
700
|
-
positions: dict[str, tuple[float, float]] = {}
|
|
701
|
-
for name, idx in positions_index.items():
|
|
702
|
-
level = levels.get(name, 0)
|
|
703
|
-
gx = round(idx * layout.grid_spacing_x)
|
|
704
|
-
gy = round(float(level) * layout.grid_spacing_y)
|
|
705
|
-
grid_positions[name] = (float(gx), float(gy))
|
|
706
|
-
iso_x, iso_y = _iso_project_center(layout, float(gx), float(gy))
|
|
707
|
-
positions[name] = (iso_x, iso_y)
|
|
708
|
-
if positions:
|
|
709
|
-
min_x = min(x for x, _ in positions.values())
|
|
710
|
-
min_y = min(y for _, y in positions.values())
|
|
711
|
-
max_x = max(x for x, _ in positions.values())
|
|
712
|
-
max_y = max(y for _, y in positions.values())
|
|
713
|
-
else:
|
|
714
|
-
min_x = min_y = 0.0
|
|
715
|
-
max_x = max_y = 0.0
|
|
716
|
-
offset_x = -min_x + layout.padding
|
|
717
|
-
offset_y = -min_y + layout.padding + layout.tile_y_offset
|
|
718
|
-
for name, (x, y) in positions.items():
|
|
719
|
-
positions[name] = (x + offset_x, y + offset_y)
|
|
720
|
-
width = max_x - min_x + layout.tile_width + layout.padding * 2 + layout.extra_pad
|
|
721
|
-
height = (
|
|
722
|
-
max_y
|
|
723
|
-
- min_y
|
|
724
|
-
+ layout.tile_height
|
|
725
|
-
+ layout.padding * 2
|
|
726
|
-
+ layout.tile_y_offset
|
|
727
|
-
+ layout.extra_pad
|
|
728
|
-
)
|
|
729
|
-
return IsoLayoutPositions(
|
|
730
|
-
layout=layout,
|
|
731
|
-
grid_positions=grid_positions,
|
|
732
|
-
positions=positions,
|
|
733
|
-
width=width,
|
|
734
|
-
height=height,
|
|
735
|
-
offset_x=offset_x,
|
|
736
|
-
offset_y=offset_y,
|
|
737
|
-
)
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
def _iso_grid_lines(
|
|
741
|
-
grid_positions: dict[str, tuple[float, float]],
|
|
742
|
-
layout: IsoLayout,
|
|
743
|
-
) -> list[str]:
|
|
744
|
-
if not grid_positions:
|
|
745
|
-
return []
|
|
746
|
-
min_gx = min(gx for gx, _ in grid_positions.values())
|
|
747
|
-
max_gx = max(gx for gx, _ in grid_positions.values())
|
|
748
|
-
min_gy = min(gy for _, gy in grid_positions.values())
|
|
749
|
-
max_gy = max(gy for _, gy in grid_positions.values())
|
|
750
|
-
pad = 12
|
|
751
|
-
gx_start = int(math.floor(min_gx)) - pad
|
|
752
|
-
gx_end = int(math.ceil(max_gx)) + pad
|
|
753
|
-
gy_start = int(math.floor(min_gy)) - pad
|
|
754
|
-
gy_end = int(math.ceil(max_gy)) + pad
|
|
755
|
-
grid_lines: list[str] = []
|
|
756
|
-
for gx in range(gx_start, gx_end + 1):
|
|
757
|
-
x1, y1 = _iso_project(layout, float(gx), float(gy_start))
|
|
758
|
-
x2, y2 = _iso_project(layout, float(gx), float(gy_end))
|
|
759
|
-
x1 += layout.padding
|
|
760
|
-
y1 += layout.padding
|
|
761
|
-
x2 += layout.padding
|
|
762
|
-
y2 += layout.padding
|
|
763
|
-
grid_lines.append(
|
|
764
|
-
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#efefef" stroke-width="0.6"/>'
|
|
765
|
-
)
|
|
766
|
-
for gy in range(gy_start, gy_end + 1):
|
|
767
|
-
x1, y1 = _iso_project(layout, float(gx_start), float(gy))
|
|
768
|
-
x2, y2 = _iso_project(layout, float(gx_end), float(gy))
|
|
769
|
-
x1 += layout.padding
|
|
770
|
-
y1 += layout.padding
|
|
771
|
-
x2 += layout.padding
|
|
772
|
-
y2 += layout.padding
|
|
773
|
-
grid_lines.append(
|
|
774
|
-
f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#efefef" stroke-width="0.6"/>'
|
|
775
|
-
)
|
|
776
|
-
return grid_lines
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
def _iso_front_anchor(
|
|
780
|
-
layout: IsoLayout,
|
|
781
|
-
*,
|
|
782
|
-
gx: float,
|
|
783
|
-
gy: float,
|
|
784
|
-
offset_x: float,
|
|
785
|
-
offset_y: float,
|
|
786
|
-
) -> tuple[float, float]:
|
|
787
|
-
iso_x, iso_y = _iso_project_center(layout, gx, gy)
|
|
788
|
-
cx = iso_x + offset_x + layout.tile_width / 2
|
|
789
|
-
cy = iso_y + offset_y + layout.tile_height / 2
|
|
790
|
-
return cx, cy
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
def _render_iso_edges(
|
|
794
|
-
lines: list[str],
|
|
795
|
-
edges: list[Edge],
|
|
796
|
-
*,
|
|
797
|
-
positions: dict[str, tuple[float, float]],
|
|
798
|
-
grid_positions: dict[str, tuple[float, float]],
|
|
799
|
-
node_types: dict[str, str],
|
|
800
|
-
layout: IsoLayout,
|
|
801
|
-
options: SvgOptions,
|
|
802
|
-
offset_x: float,
|
|
803
|
-
offset_y: float,
|
|
804
|
-
node_port_labels: dict[str, str],
|
|
805
|
-
node_port_prefix: dict[str, str],
|
|
806
|
-
) -> None:
|
|
807
|
-
for edge in edges:
|
|
808
|
-
_record_iso_edge_label(edge, node_types, node_port_labels, node_port_prefix)
|
|
809
|
-
for edge in sorted(edges, key=lambda item: item.poe):
|
|
810
|
-
if edge.left not in positions or edge.right not in positions:
|
|
811
|
-
continue
|
|
812
|
-
src_grid = grid_positions.get(edge.left)
|
|
813
|
-
dst_grid = grid_positions.get(edge.right)
|
|
814
|
-
if not src_grid or not dst_grid:
|
|
815
|
-
continue
|
|
816
|
-
color = "url(#iso-link-poe)" if edge.poe else "url(#iso-link-standard)"
|
|
817
|
-
width_px = 5 if edge.poe else 4
|
|
818
|
-
src_gx, src_gy = float(src_grid[0]), float(src_grid[1])
|
|
819
|
-
dst_gx, dst_gy = float(dst_grid[0]), float(dst_grid[1])
|
|
820
|
-
src_cx, src_cy = _iso_front_anchor(
|
|
821
|
-
layout, gx=src_gx, gy=src_gy, offset_x=offset_x, offset_y=offset_y
|
|
822
|
-
)
|
|
823
|
-
dst_cx, dst_cy = _iso_front_anchor(
|
|
824
|
-
layout, gx=dst_gx, gy=dst_gy, offset_x=offset_x, offset_y=offset_y
|
|
825
|
-
)
|
|
826
|
-
path_cmds = _iso_edge_path(
|
|
827
|
-
layout,
|
|
828
|
-
offset_x,
|
|
829
|
-
offset_y,
|
|
830
|
-
src_gx,
|
|
831
|
-
src_gy,
|
|
832
|
-
dst_gx,
|
|
833
|
-
dst_gy,
|
|
834
|
-
src_cx,
|
|
835
|
-
src_cy,
|
|
836
|
-
dst_cx,
|
|
837
|
-
dst_cy,
|
|
838
|
-
)
|
|
839
|
-
dash = ' stroke-dasharray="8 6"' if edge.wireless else ""
|
|
840
|
-
left_attr = _escape_attr(edge.left, quote=True)
|
|
841
|
-
right_attr = _escape_attr(edge.right, quote=True)
|
|
842
|
-
lines.append(
|
|
843
|
-
f'<path d="{" ".join(path_cmds)}" stroke="{color}" stroke-width="{width_px}" '
|
|
844
|
-
f'fill="none" stroke-linecap="round" stroke-linejoin="round"{dash} '
|
|
845
|
-
f'data-edge-left="{left_attr}" data-edge-right="{right_attr}"/>'
|
|
846
|
-
)
|
|
847
|
-
if edge.poe:
|
|
848
|
-
icon_x = dst_cx
|
|
849
|
-
icon_y = dst_cy - layout.tile_height * 0.4
|
|
850
|
-
lines.append(
|
|
851
|
-
f'<text x="{icon_x}" y="{icon_y}" text-anchor="middle" fill="#1e88e5" '
|
|
852
|
-
f'font-size="{max(options.font_size, 10)}">⚡</text>'
|
|
853
|
-
)
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
def _iso_edge_path(
|
|
857
|
-
layout: IsoLayout,
|
|
858
|
-
offset_x: float,
|
|
859
|
-
offset_y: float,
|
|
860
|
-
src_gx: float,
|
|
861
|
-
src_gy: float,
|
|
862
|
-
dst_gx: float,
|
|
863
|
-
dst_gy: float,
|
|
864
|
-
src_cx: float,
|
|
865
|
-
src_cy: float,
|
|
866
|
-
dst_cx: float,
|
|
867
|
-
dst_cy: float,
|
|
868
|
-
) -> list[str]:
|
|
869
|
-
dx = dst_gx - src_gx
|
|
870
|
-
dy = dst_gy - src_gy
|
|
871
|
-
if dx == 0 or dy == 0:
|
|
872
|
-
return [f"M {src_cx} {src_cy}", f"L {dst_cx} {dst_cy}"]
|
|
873
|
-
elbow_gx, elbow_gy = dst_gx, src_gy
|
|
874
|
-
elbow_cx, elbow_cy = _iso_front_anchor(
|
|
875
|
-
layout,
|
|
876
|
-
gx=elbow_gx,
|
|
877
|
-
gy=elbow_gy,
|
|
878
|
-
offset_x=offset_x,
|
|
879
|
-
offset_y=offset_y,
|
|
880
|
-
)
|
|
881
|
-
return [
|
|
882
|
-
f"M {src_cx} {src_cy}",
|
|
883
|
-
f"L {elbow_cx} {elbow_cy}",
|
|
884
|
-
f"L {dst_cx} {dst_cy}",
|
|
885
|
-
]
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
def _record_iso_edge_label(
|
|
889
|
-
edge: Edge,
|
|
890
|
-
node_types: dict[str, str],
|
|
891
|
-
node_port_labels: dict[str, str],
|
|
892
|
-
node_port_prefix: dict[str, str],
|
|
893
|
-
) -> None:
|
|
894
|
-
if not edge.label:
|
|
895
|
-
return
|
|
896
|
-
label_text = _compact_edge_label(edge.label, left_node=edge.left, right_node=edge.right)
|
|
897
|
-
left_type = node_types.get(edge.left, "other")
|
|
898
|
-
right_type = node_types.get(edge.right, "other")
|
|
899
|
-
client_node = None
|
|
900
|
-
upstream_node = None
|
|
901
|
-
if left_type == "client" and right_type != "client":
|
|
902
|
-
client_node = edge.left
|
|
903
|
-
upstream_node = edge.right
|
|
904
|
-
elif right_type == "client" and left_type != "client":
|
|
905
|
-
client_node = edge.right
|
|
906
|
-
upstream_node = edge.left
|
|
907
|
-
if client_node and upstream_node:
|
|
908
|
-
if "<->" not in label_text:
|
|
909
|
-
upstream_part = edge.label.split("<->", 1)[0].strip()
|
|
910
|
-
port_text = _extract_port_text(upstream_part) or label_text
|
|
911
|
-
node_port_labels.setdefault(client_node, f"{upstream_node}: {port_text}")
|
|
912
|
-
node_port_prefix.setdefault(client_node, _shorten_prefix(upstream_node))
|
|
913
|
-
return
|
|
914
|
-
upstream_part = edge.label.split("<->", 1)[0].strip()
|
|
915
|
-
upstream_name = _extract_device_name(upstream_part) or edge.left
|
|
916
|
-
if label_text.lower().startswith("port "):
|
|
917
|
-
label_text = f"{upstream_name} {label_text}"
|
|
918
|
-
node_port_labels.setdefault(edge.right, label_text)
|
|
919
|
-
node_port_prefix.setdefault(edge.right, _shorten_prefix(edge.left))
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
def _iso_node_polygons(
|
|
923
|
-
x: float,
|
|
924
|
-
y: float,
|
|
925
|
-
tile_w: float,
|
|
926
|
-
tile_h: float,
|
|
927
|
-
node_depth: float,
|
|
928
|
-
) -> tuple[list[tuple[float, float]], list[tuple[float, float]], list[tuple[float, float]]]:
|
|
929
|
-
top = [
|
|
930
|
-
(x + tile_w / 2, y),
|
|
931
|
-
(x + tile_w, y + tile_h / 2),
|
|
932
|
-
(x + tile_w / 2, y + tile_h),
|
|
933
|
-
(x, y + tile_h / 2),
|
|
934
|
-
]
|
|
935
|
-
left = [
|
|
936
|
-
(x, y + tile_h / 2),
|
|
937
|
-
(x + tile_w / 2, y + tile_h),
|
|
938
|
-
(x + tile_w / 2, y + tile_h + node_depth),
|
|
939
|
-
(x, y + tile_h / 2 + node_depth),
|
|
940
|
-
]
|
|
941
|
-
right = [
|
|
942
|
-
(x + tile_w, y + tile_h / 2),
|
|
943
|
-
(x + tile_w / 2, y + tile_h),
|
|
944
|
-
(x + tile_w / 2, y + tile_h + node_depth),
|
|
945
|
-
(x + tile_w, y + tile_h / 2 + node_depth),
|
|
946
|
-
]
|
|
947
|
-
return top, left, right
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
def _iso_render_faces(
|
|
951
|
-
lines: list[str],
|
|
952
|
-
*,
|
|
953
|
-
top: list[tuple[float, float]],
|
|
954
|
-
left: list[tuple[float, float]],
|
|
955
|
-
right: list[tuple[float, float]],
|
|
956
|
-
fill: str,
|
|
957
|
-
stroke: str,
|
|
958
|
-
left_fill: str,
|
|
959
|
-
right_fill: str,
|
|
960
|
-
node_depth: float,
|
|
961
|
-
) -> None:
|
|
962
|
-
if node_depth > 0:
|
|
963
|
-
lines.append(
|
|
964
|
-
f'<polygon points="{" ".join(f"{px},{py}" for px, py in left)}" '
|
|
965
|
-
f'fill="{left_fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
966
|
-
)
|
|
967
|
-
lines.append(
|
|
968
|
-
f'<polygon points="{" ".join(f"{px},{py}" for px, py in right)}" '
|
|
969
|
-
f'fill="{right_fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
970
|
-
)
|
|
971
|
-
lines.append(
|
|
972
|
-
f'<polygon points="{" ".join(f"{px},{py}" for px, py in top)}" '
|
|
973
|
-
f'fill="{fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
974
|
-
)
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
def _render_iso_port_label(
|
|
978
|
-
lines: list[str],
|
|
979
|
-
*,
|
|
980
|
-
port_label: str,
|
|
981
|
-
node_type: str,
|
|
982
|
-
prefix: str,
|
|
983
|
-
center_x: float,
|
|
984
|
-
center_y: float,
|
|
985
|
-
tile_w: float,
|
|
986
|
-
tile_h: float,
|
|
987
|
-
fill: str,
|
|
988
|
-
stroke: str,
|
|
989
|
-
left_fill: str,
|
|
990
|
-
right_fill: str,
|
|
991
|
-
font_size: int,
|
|
992
|
-
) -> tuple[float, float]:
|
|
993
|
-
tile_width = tile_w
|
|
994
|
-
tile_height = tile_h
|
|
995
|
-
stack_depth = tile_h / 2
|
|
996
|
-
label_center_x = center_x
|
|
997
|
-
label_center_y = center_y - stack_depth
|
|
998
|
-
top_points = _iso_tile_points(label_center_x, label_center_y, tile_width, tile_height)
|
|
999
|
-
tile_points = _points_to_svg(top_points)
|
|
1000
|
-
bottom_points = [(px, py + stack_depth) for px, py in top_points]
|
|
1001
|
-
right_face = [
|
|
1002
|
-
top_points[1],
|
|
1003
|
-
top_points[2],
|
|
1004
|
-
bottom_points[2],
|
|
1005
|
-
bottom_points[1],
|
|
1006
|
-
]
|
|
1007
|
-
left_face = [
|
|
1008
|
-
top_points[3],
|
|
1009
|
-
top_points[2],
|
|
1010
|
-
bottom_points[2],
|
|
1011
|
-
bottom_points[3],
|
|
1012
|
-
]
|
|
1013
|
-
left_points = " ".join(f"{px},{py}" for px, py in left_face)
|
|
1014
|
-
right_points = " ".join(f"{px},{py}" for px, py in right_face)
|
|
1015
|
-
lines.append(
|
|
1016
|
-
f'<polygon class="label-tile-side" points="{left_points}" '
|
|
1017
|
-
f'fill="{left_fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
1018
|
-
)
|
|
1019
|
-
lines.append(
|
|
1020
|
-
f'<polygon class="label-tile-side" points="{right_points}" '
|
|
1021
|
-
f'fill="{right_fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
1022
|
-
)
|
|
1023
|
-
lines.append(
|
|
1024
|
-
f'<polygon class="label-tile" points="{tile_points}" '
|
|
1025
|
-
f'fill="{fill}" stroke="{stroke}" stroke-width="1"/>'
|
|
1026
|
-
)
|
|
1027
|
-
left_edge_top = top_points[0]
|
|
1028
|
-
left_edge_bottom = top_points[3]
|
|
1029
|
-
edge_len = math.hypot(
|
|
1030
|
-
left_edge_bottom[0] - left_edge_top[0],
|
|
1031
|
-
left_edge_bottom[1] - left_edge_top[1],
|
|
1032
|
-
)
|
|
1033
|
-
max_chars = max(6, int((edge_len * 0.85) / (font_size * 0.6)))
|
|
1034
|
-
front_lines = _format_port_label_lines(
|
|
1035
|
-
port_label,
|
|
1036
|
-
node_type=node_type,
|
|
1037
|
-
prefix=prefix,
|
|
1038
|
-
max_chars=max_chars,
|
|
1039
|
-
)
|
|
1040
|
-
if front_lines:
|
|
1041
|
-
text_x, text_y, edge_angle = _iso_front_text_position(top_points, tile_w, tile_h)
|
|
1042
|
-
_render_iso_text(
|
|
1043
|
-
lines,
|
|
1044
|
-
text_x=text_x,
|
|
1045
|
-
text_y=text_y,
|
|
1046
|
-
angle=edge_angle,
|
|
1047
|
-
text_lines=front_lines,
|
|
1048
|
-
font_size=font_size,
|
|
1049
|
-
fill="#555",
|
|
1050
|
-
)
|
|
1051
|
-
return label_center_x, label_center_y
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
def _render_iso_node(
|
|
1055
|
-
lines: list[str],
|
|
1056
|
-
*,
|
|
1057
|
-
name: str,
|
|
1058
|
-
x: float,
|
|
1059
|
-
y: float,
|
|
1060
|
-
node_type: str,
|
|
1061
|
-
icons: dict[str, str],
|
|
1062
|
-
options: SvgOptions,
|
|
1063
|
-
port_label: str | None,
|
|
1064
|
-
port_prefix: str | None,
|
|
1065
|
-
layout: IsoLayout,
|
|
1066
|
-
) -> None:
|
|
1067
|
-
fill, stroke = _TYPE_COLORS.get(node_type, _TYPE_COLORS["other"])
|
|
1068
|
-
fill = f"url(#iso-node-{node_type})"
|
|
1069
|
-
node_depth = 0.0
|
|
1070
|
-
tile_w = layout.tile_width
|
|
1071
|
-
tile_h = layout.tile_height
|
|
1072
|
-
group_attrs = _svg_node_group_attrs(None, name, node_type)
|
|
1073
|
-
lines.append(f"<g{group_attrs}>")
|
|
1074
|
-
lines.append(f"<title>{_escape_text(name)}</title>")
|
|
1075
|
-
top, left, right = _iso_node_polygons(x, y, tile_w, tile_h, node_depth)
|
|
1076
|
-
lines.append(
|
|
1077
|
-
f'<polygon points="{_points_to_svg(top)}" fill="transparent" '
|
|
1078
|
-
'pointer-events="all" class="node-hitbox"/>'
|
|
1079
|
-
)
|
|
1080
|
-
left_fill = "#d0d0d0" if node_type == "other" else "#dcdcdc"
|
|
1081
|
-
right_fill = "#c2c2c2" if node_type == "other" else "#c8c8c8"
|
|
1082
|
-
_iso_render_faces(
|
|
1083
|
-
lines,
|
|
1084
|
-
top=top,
|
|
1085
|
-
left=left,
|
|
1086
|
-
right=right,
|
|
1087
|
-
fill=fill,
|
|
1088
|
-
stroke=stroke,
|
|
1089
|
-
left_fill=left_fill,
|
|
1090
|
-
right_fill=right_fill,
|
|
1091
|
-
node_depth=node_depth,
|
|
1092
|
-
)
|
|
1093
|
-
icon_href = icons.get(node_type, icons.get("other"))
|
|
1094
|
-
center_x = x + tile_w / 2
|
|
1095
|
-
center_y = y + tile_h / 2
|
|
1096
|
-
icon_center_x = center_x
|
|
1097
|
-
icon_center_y = center_y
|
|
1098
|
-
iso_icon_size = min(tile_w, tile_h) * 1.26
|
|
1099
|
-
if port_label:
|
|
1100
|
-
font_size = max(options.font_size - 2, 8)
|
|
1101
|
-
prefix = port_prefix or "switch"
|
|
1102
|
-
icon_center_x, icon_center_y = _render_iso_port_label(
|
|
1103
|
-
lines,
|
|
1104
|
-
port_label=port_label,
|
|
1105
|
-
node_type=node_type,
|
|
1106
|
-
prefix=prefix,
|
|
1107
|
-
center_x=center_x,
|
|
1108
|
-
center_y=center_y,
|
|
1109
|
-
tile_w=tile_w,
|
|
1110
|
-
tile_h=tile_h,
|
|
1111
|
-
fill=fill,
|
|
1112
|
-
stroke=stroke,
|
|
1113
|
-
left_fill=left_fill,
|
|
1114
|
-
right_fill=right_fill,
|
|
1115
|
-
font_size=font_size,
|
|
1116
|
-
)
|
|
1117
|
-
if node_type == "ap":
|
|
1118
|
-
icon_center_y -= tile_h * 0.4
|
|
1119
|
-
if icon_href:
|
|
1120
|
-
icon_x = icon_center_x - iso_icon_size / 2
|
|
1121
|
-
icon_lift = tile_h * (0.02 if port_label else 0.04)
|
|
1122
|
-
icon_y = icon_center_y - iso_icon_size / 2 - icon_lift - tile_h * 0.05
|
|
1123
|
-
if node_type == "client":
|
|
1124
|
-
icon_y -= tile_h * 0.05
|
|
1125
|
-
lines.append(
|
|
1126
|
-
f'<image href="{icon_href}" x="{icon_x}" y="{icon_y}" '
|
|
1127
|
-
f'width="{iso_icon_size}" height="{iso_icon_size}" '
|
|
1128
|
-
f'preserveAspectRatio="xMidYMid meet"/>'
|
|
1129
|
-
)
|
|
1130
|
-
name_font_size = max(options.font_size - 2, 8)
|
|
1131
|
-
name_x, name_y, name_angle = _iso_name_label_position(
|
|
1132
|
-
top,
|
|
1133
|
-
tile_width=tile_w,
|
|
1134
|
-
tile_height=tile_h,
|
|
1135
|
-
font_size=name_font_size,
|
|
1136
|
-
)
|
|
1137
|
-
name_transform = (
|
|
1138
|
-
f"translate({name_x} {name_y}) rotate({name_angle}) skewX(30) "
|
|
1139
|
-
f"translate({-name_x} {-name_y})"
|
|
1140
|
-
)
|
|
1141
|
-
lines.append(
|
|
1142
|
-
f'<text x="{name_x}" y="{name_y}" text-anchor="middle" fill="#1f1f1f" '
|
|
1143
|
-
f'font-size="{name_font_size}" transform="{name_transform}">{_escape_text(name)}</text>'
|
|
1144
|
-
)
|
|
1145
|
-
lines.append("</g>")
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
def _render_iso_nodes(
|
|
1149
|
-
lines: list[str],
|
|
1150
|
-
*,
|
|
1151
|
-
positions: dict[str, tuple[float, float]],
|
|
1152
|
-
node_types: dict[str, str],
|
|
1153
|
-
icons: dict[str, str],
|
|
1154
|
-
options: SvgOptions,
|
|
1155
|
-
layout: IsoLayout,
|
|
1156
|
-
node_port_labels: dict[str, str],
|
|
1157
|
-
node_port_prefix: dict[str, str],
|
|
1158
|
-
) -> None:
|
|
1159
|
-
for name, (x, y) in positions.items():
|
|
1160
|
-
_render_iso_node(
|
|
1161
|
-
lines,
|
|
1162
|
-
name=name,
|
|
1163
|
-
x=x,
|
|
1164
|
-
y=y,
|
|
1165
|
-
node_type=node_types.get(name, "other"),
|
|
1166
|
-
icons=icons,
|
|
1167
|
-
options=options,
|
|
1168
|
-
port_label=node_port_labels.get(name),
|
|
1169
|
-
port_prefix=node_port_prefix.get(name),
|
|
1170
|
-
layout=layout,
|
|
1171
|
-
)
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
def render_svg_isometric(
|
|
1175
|
-
edges: list[Edge],
|
|
1176
|
-
*,
|
|
1177
|
-
node_types: dict[str, str],
|
|
1178
|
-
options: SvgOptions | None = None,
|
|
1179
|
-
theme: SvgTheme = DEFAULT_THEME,
|
|
1180
|
-
) -> str:
|
|
1181
|
-
options = options or SvgOptions()
|
|
1182
|
-
icons = _load_isometric_icons()
|
|
1183
|
-
layout_positions = _iso_layout_positions(edges, node_types, options)
|
|
1184
|
-
layout = layout_positions.layout
|
|
1185
|
-
grid_positions = layout_positions.grid_positions
|
|
1186
|
-
positions = layout_positions.positions
|
|
1187
|
-
|
|
1188
|
-
out_width = options.width or int(layout_positions.width)
|
|
1189
|
-
out_height = options.height or int(layout_positions.height)
|
|
1190
|
-
|
|
1191
|
-
lines = [
|
|
1192
|
-
f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
|
|
1193
|
-
f'viewBox="0 0 {layout_positions.width} {layout_positions.height}">',
|
|
1194
|
-
svg_defs("iso", theme),
|
|
1195
|
-
(
|
|
1196
|
-
"<style>text{font-family:Arial,Helvetica,sans-serif;font-size:"
|
|
1197
|
-
f"{options.font_size}px;"
|
|
1198
|
-
"}</style>"
|
|
1199
|
-
),
|
|
1200
|
-
]
|
|
1201
|
-
|
|
1202
|
-
grid_lines = _iso_grid_lines(grid_positions, layout)
|
|
1203
|
-
if grid_lines:
|
|
1204
|
-
lines.append('<g class="iso-grid" opacity="0.7">')
|
|
1205
|
-
lines.extend(grid_lines)
|
|
1206
|
-
lines.append("</g>")
|
|
1207
|
-
|
|
1208
|
-
node_port_labels: dict[str, str] = {}
|
|
1209
|
-
node_port_prefix: dict[str, str] = {}
|
|
1210
|
-
_render_iso_edges(
|
|
1211
|
-
lines,
|
|
1212
|
-
edges,
|
|
1213
|
-
positions=positions,
|
|
1214
|
-
grid_positions=grid_positions,
|
|
1215
|
-
node_types=node_types,
|
|
1216
|
-
layout=layout,
|
|
1217
|
-
options=options,
|
|
1218
|
-
offset_x=layout_positions.offset_x,
|
|
1219
|
-
offset_y=layout_positions.offset_y,
|
|
1220
|
-
node_port_labels=node_port_labels,
|
|
1221
|
-
node_port_prefix=node_port_prefix,
|
|
1222
|
-
)
|
|
1223
|
-
_render_iso_nodes(
|
|
1224
|
-
lines,
|
|
1225
|
-
positions=positions,
|
|
1226
|
-
node_types=node_types,
|
|
1227
|
-
icons=icons,
|
|
1228
|
-
options=options,
|
|
1229
|
-
layout=layout,
|
|
1230
|
-
node_port_labels=node_port_labels,
|
|
1231
|
-
node_port_prefix=node_port_prefix,
|
|
1232
|
-
)
|
|
1233
|
-
|
|
1234
|
-
lines.append("</svg>")
|
|
1235
|
-
return "\n".join(lines) + "\n"
|