unifi-network-maps 1.4.15__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.
Files changed (74) hide show
  1. unifi_network_maps/__init__.py +1 -1
  2. unifi_network_maps/adapters/unifi.py +80 -96
  3. unifi_network_maps/assets/icons/modern/ap.svg +9 -0
  4. unifi_network_maps/assets/icons/modern/camera.svg +9 -0
  5. unifi_network_maps/assets/icons/modern/client.svg +9 -0
  6. unifi_network_maps/assets/icons/modern/game_console.svg +10 -0
  7. unifi_network_maps/assets/icons/modern/gateway.svg +17 -0
  8. unifi_network_maps/assets/icons/modern/iot.svg +9 -0
  9. unifi_network_maps/assets/icons/modern/nas.svg +9 -0
  10. unifi_network_maps/assets/icons/modern/other.svg +10 -0
  11. unifi_network_maps/assets/icons/modern/phone.svg +10 -0
  12. unifi_network_maps/assets/icons/modern/printer.svg +9 -0
  13. unifi_network_maps/assets/icons/modern/speaker.svg +10 -0
  14. unifi_network_maps/assets/icons/modern/switch.svg +10 -0
  15. unifi_network_maps/assets/icons/modern/tv.svg +10 -0
  16. unifi_network_maps/assets/icons/modern-flat/ap.svg +5 -0
  17. unifi_network_maps/assets/icons/modern-flat/camera.svg +5 -0
  18. unifi_network_maps/assets/icons/modern-flat/client.svg +5 -0
  19. unifi_network_maps/assets/icons/modern-flat/game_console.svg +6 -0
  20. unifi_network_maps/assets/icons/modern-flat/gateway.svg +13 -0
  21. unifi_network_maps/assets/icons/modern-flat/iot.svg +5 -0
  22. unifi_network_maps/assets/icons/modern-flat/nas.svg +5 -0
  23. unifi_network_maps/assets/icons/modern-flat/other.svg +6 -0
  24. unifi_network_maps/assets/icons/modern-flat/phone.svg +6 -0
  25. unifi_network_maps/assets/icons/modern-flat/printer.svg +5 -0
  26. unifi_network_maps/assets/icons/modern-flat/speaker.svg +6 -0
  27. unifi_network_maps/assets/icons/modern-flat/switch.svg +6 -0
  28. unifi_network_maps/assets/icons/modern-flat/tv.svg +6 -0
  29. unifi_network_maps/assets/themes/dark.yaml +53 -10
  30. unifi_network_maps/assets/themes/default.yaml +34 -0
  31. unifi_network_maps/assets/themes/minimal-dark.yaml +98 -0
  32. unifi_network_maps/assets/themes/minimal.yaml +92 -0
  33. unifi_network_maps/assets/themes/unifi-dark.yaml +97 -0
  34. unifi_network_maps/assets/themes/unifi.yaml +92 -0
  35. unifi_network_maps/cli/args.py +54 -0
  36. unifi_network_maps/cli/main.py +18 -7
  37. unifi_network_maps/cli/render.py +79 -27
  38. unifi_network_maps/cli/runtime.py +29 -15
  39. unifi_network_maps/io/debug.py +2 -1
  40. unifi_network_maps/io/export.py +19 -13
  41. unifi_network_maps/io/mock_data.py +5 -3
  42. unifi_network_maps/io/paths.py +5 -3
  43. unifi_network_maps/model/classify.py +199 -0
  44. unifi_network_maps/model/clients.py +271 -0
  45. unifi_network_maps/model/connection.py +37 -0
  46. unifi_network_maps/model/diff.py +544 -0
  47. unifi_network_maps/model/edges.py +558 -0
  48. unifi_network_maps/model/helpers.py +64 -0
  49. unifi_network_maps/model/lldp.py +20 -25
  50. unifi_network_maps/model/mock.py +110 -23
  51. unifi_network_maps/model/snapshot.py +294 -0
  52. unifi_network_maps/model/topology.py +143 -951
  53. unifi_network_maps/model/topology_coerce.py +339 -0
  54. unifi_network_maps/model/vlans.py +32 -46
  55. unifi_network_maps/model/wan.py +132 -0
  56. unifi_network_maps/render/device_ports_md.py +39 -97
  57. unifi_network_maps/render/device_summary.py +53 -0
  58. unifi_network_maps/render/lldp_md.py +29 -219
  59. unifi_network_maps/render/markdown_tables.py +8 -0
  60. unifi_network_maps/render/mermaid.py +11 -2
  61. unifi_network_maps/render/mkdocs.py +2 -1
  62. unifi_network_maps/render/svg.py +566 -908
  63. unifi_network_maps/render/svg_icons.py +231 -0
  64. unifi_network_maps/render/svg_isometric.py +1196 -0
  65. unifi_network_maps/render/svg_labels.py +184 -0
  66. unifi_network_maps/render/svg_theme.py +166 -32
  67. unifi_network_maps/render/theme.py +86 -1
  68. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
  69. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/RECORD +73 -31
  70. unifi_network_maps/assets/icons/isometric/printer.svg +0 -122
  71. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
  72. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
  73. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
  74. {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
@@ -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 _escape_attr
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
- @dataclass(frozen=True)
30
- class IsoLayout:
31
- iso_angle: float
32
- tile_width: float
33
- tile_height: float
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
- @dataclass(frozen=True)
44
- class IsoLayoutPositions:
45
- layout: IsoLayout
46
- grid_positions: dict[str, tuple[float, float]]
47
- positions: dict[str, tuple[float, float]]
48
- width: float
49
- height: float
50
- offset_x: float
51
- offset_y: float
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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
- def _format_port_label_lines(
164
- port_label: str,
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 _render_iso_text(
231
- lines: list[str],
232
- *,
233
- text_x: float,
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
- def _iso_name_label_position(
257
- top_points: list[tuple[float, float]],
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
- def _load_isometric_icons() -> dict[str, str]:
329
- base = Path(__file__).resolve().parents[1] / "assets" / "icons" / "isometric"
330
- icons: dict[str, str] = {}
331
- for node_type, filename in _ISO_ICON_FILES.items():
332
- path = base / filename
333
- if not path.exists():
334
- continue
335
- data = path.read_bytes()
336
- encoded = base64.b64encode(data).decode("ascii")
337
- icons[node_type] = f"data:image/svg+xml;base64,{encoded}"
338
- return icons
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
- positions, width, height = _layout_nodes(edges, node_types, options)
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
- "<style>text{font-family:Arial,Helvetica,sans-serif;font-size:"
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
- node_port_labels, node_port_prefix = _render_svg_edges(
498
- lines, edges, positions, node_types, options
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
- if math.isclose(src_cx, dst_cx, abs_tol=0.01):
539
- elbow_x = src_cx + 0.5
540
- path = (
541
- f"M {src_cx} {src_bottom} L {src_cx} {mid_y} "
542
- f"L {elbow_x} {mid_y} L {dst_cx} {mid_y} L {dst_cx} {dst_top}"
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
- f"M {src_cx} {src_bottom} L {src_cx} {mid_y} "
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
- icon_x = dst_cx
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
- group_attrs = _svg_node_group_attrs(node_data, name, node_type)
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="#555" font-size="{font_size}">'
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}" fill="#1f1f1f" text-anchor="start">{safe_name}</text>'
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}="{_escape_attr(value, quote=True)}"' for key, value in attrs.items()]
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"