unifi-network-maps 1.4.11__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 (99) hide show
  1. unifi_network_maps/__init__.py +1 -0
  2. unifi_network_maps/__main__.py +8 -0
  3. unifi_network_maps/adapters/__init__.py +1 -0
  4. unifi_network_maps/adapters/config.py +49 -0
  5. unifi_network_maps/adapters/unifi.py +457 -0
  6. unifi_network_maps/assets/__init__.py +0 -0
  7. unifi_network_maps/assets/icons/__init__.py +0 -0
  8. unifi_network_maps/assets/icons/access-point.svg +1 -0
  9. unifi_network_maps/assets/icons/isometric/ISOPACKS_LICENSE +7 -0
  10. unifi_network_maps/assets/icons/isometric/block.svg +23 -0
  11. unifi_network_maps/assets/icons/isometric/cache.svg +48 -0
  12. unifi_network_maps/assets/icons/isometric/cardterminal.svg +316 -0
  13. unifi_network_maps/assets/icons/isometric/cloud.svg +89 -0
  14. unifi_network_maps/assets/icons/isometric/cronjob.svg +409 -0
  15. unifi_network_maps/assets/icons/isometric/cube.svg +24 -0
  16. unifi_network_maps/assets/icons/isometric/desktop.svg +107 -0
  17. unifi_network_maps/assets/icons/isometric/diamond.svg +23 -0
  18. unifi_network_maps/assets/icons/isometric/dns.svg +46 -0
  19. unifi_network_maps/assets/icons/isometric/document.svg +62 -0
  20. unifi_network_maps/assets/icons/isometric/firewall.svg +200 -0
  21. unifi_network_maps/assets/icons/isometric/function-module.svg +215 -0
  22. unifi_network_maps/assets/icons/isometric/image.svg +65 -0
  23. unifi_network_maps/assets/icons/isometric/laptop.svg +37 -0
  24. unifi_network_maps/assets/icons/isometric/loadbalancer.svg +65 -0
  25. unifi_network_maps/assets/icons/isometric/lock.svg +155 -0
  26. unifi_network_maps/assets/icons/isometric/mail.svg +35 -0
  27. unifi_network_maps/assets/icons/isometric/mailmultiple.svg +91 -0
  28. unifi_network_maps/assets/icons/isometric/mobiledevice.svg +66 -0
  29. unifi_network_maps/assets/icons/isometric/office.svg +136 -0
  30. unifi_network_maps/assets/icons/isometric/package-module.svg +39 -0
  31. unifi_network_maps/assets/icons/isometric/paymentcard.svg +92 -0
  32. unifi_network_maps/assets/icons/isometric/plane.svg +1 -0
  33. unifi_network_maps/assets/icons/isometric/printer.svg +122 -0
  34. unifi_network_maps/assets/icons/isometric/pyramid.svg +28 -0
  35. unifi_network_maps/assets/icons/isometric/queue.svg +38 -0
  36. unifi_network_maps/assets/icons/isometric/router.svg +39 -0
  37. unifi_network_maps/assets/icons/isometric/server.svg +112 -0
  38. unifi_network_maps/assets/icons/isometric/speech.svg +70 -0
  39. unifi_network_maps/assets/icons/isometric/sphere.svg +15 -0
  40. unifi_network_maps/assets/icons/isometric/storage.svg +92 -0
  41. unifi_network_maps/assets/icons/isometric/switch-module.svg +45 -0
  42. unifi_network_maps/assets/icons/isometric/tower.svg +50 -0
  43. unifi_network_maps/assets/icons/isometric/truck-2.svg +1 -0
  44. unifi_network_maps/assets/icons/isometric/truck.svg +1 -0
  45. unifi_network_maps/assets/icons/isometric/user.svg +231 -0
  46. unifi_network_maps/assets/icons/isometric/vm.svg +50 -0
  47. unifi_network_maps/assets/icons/laptop.svg +1 -0
  48. unifi_network_maps/assets/icons/router-network.svg +1 -0
  49. unifi_network_maps/assets/icons/server-network.svg +1 -0
  50. unifi_network_maps/assets/icons/server.svg +1 -0
  51. unifi_network_maps/assets/themes/dark.yaml +50 -0
  52. unifi_network_maps/assets/themes/default.yaml +47 -0
  53. unifi_network_maps/cli/__init__.py +5 -0
  54. unifi_network_maps/cli/__main__.py +8 -0
  55. unifi_network_maps/cli/args.py +166 -0
  56. unifi_network_maps/cli/main.py +134 -0
  57. unifi_network_maps/cli/render.py +255 -0
  58. unifi_network_maps/cli/runtime.py +157 -0
  59. unifi_network_maps/io/__init__.py +1 -0
  60. unifi_network_maps/io/debug.py +60 -0
  61. unifi_network_maps/io/export.py +32 -0
  62. unifi_network_maps/io/mkdocs_assets.py +21 -0
  63. unifi_network_maps/io/mock_data.py +23 -0
  64. unifi_network_maps/io/mock_generate.py +7 -0
  65. unifi_network_maps/model/__init__.py +1 -0
  66. unifi_network_maps/model/labels.py +35 -0
  67. unifi_network_maps/model/lldp.py +99 -0
  68. unifi_network_maps/model/mock.py +307 -0
  69. unifi_network_maps/model/ports.py +23 -0
  70. unifi_network_maps/model/topology.py +909 -0
  71. unifi_network_maps/render/__init__.py +1 -0
  72. unifi_network_maps/render/device_ports_md.py +492 -0
  73. unifi_network_maps/render/legend.py +30 -0
  74. unifi_network_maps/render/lldp_md.py +352 -0
  75. unifi_network_maps/render/markdown_tables.py +21 -0
  76. unifi_network_maps/render/mermaid.py +273 -0
  77. unifi_network_maps/render/mermaid_theme.py +56 -0
  78. unifi_network_maps/render/mkdocs.py +167 -0
  79. unifi_network_maps/render/svg.py +1235 -0
  80. unifi_network_maps/render/svg_theme.py +64 -0
  81. unifi_network_maps/render/templates/device_port_block.md.j2 +5 -0
  82. unifi_network_maps/render/templates/legend_compact.html.j2 +14 -0
  83. unifi_network_maps/render/templates/lldp_device_section.md.j2 +15 -0
  84. unifi_network_maps/render/templates/markdown_section.md.j2 +3 -0
  85. unifi_network_maps/render/templates/mermaid_legend.mmd.j2 +30 -0
  86. unifi_network_maps/render/templates/mkdocs_document.md.j2 +23 -0
  87. unifi_network_maps/render/templates/mkdocs_dual_theme_style.html.j2 +8 -0
  88. unifi_network_maps/render/templates/mkdocs_html_block.html.j2 +2 -0
  89. unifi_network_maps/render/templates/mkdocs_legend.css.j2 +29 -0
  90. unifi_network_maps/render/templates/mkdocs_legend.js.j2 +18 -0
  91. unifi_network_maps/render/templates/mkdocs_mermaid_block.md.j2 +4 -0
  92. unifi_network_maps/render/templating.py +19 -0
  93. unifi_network_maps/render/theme.py +109 -0
  94. unifi_network_maps-1.4.11.dist-info/METADATA +290 -0
  95. unifi_network_maps-1.4.11.dist-info/RECORD +99 -0
  96. unifi_network_maps-1.4.11.dist-info/WHEEL +5 -0
  97. unifi_network_maps-1.4.11.dist-info/entry_points.txt +2 -0
  98. unifi_network_maps-1.4.11.dist-info/licenses/LICENSE +21 -0
  99. unifi_network_maps-1.4.11.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1235 @@
1
+ """SVG rendering for orthogonal network diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import math
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from html import escape as _escape_attr
10
+ from pathlib import Path
11
+
12
+ from ..model.topology import Edge
13
+ from .svg_theme import DEFAULT_THEME, SvgTheme, svg_defs
14
+
15
+
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
+
28
+
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
41
+
42
+
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)
161
+
162
+
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
228
+
229
+
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>")
254
+
255
+
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
326
+
327
+
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
339
+
340
+
341
+ def _layout_nodes(
342
+ edges: list[Edge], node_types: dict[str, str], options: SvgOptions
343
+ ) -> tuple[dict[str, tuple[float, float]], int, int]:
344
+ positions_index, levels = _tree_layout_indices(edges, node_types)
345
+ positions: dict[str, tuple[float, float]] = {}
346
+ max_index = max(positions_index.values(), default=0.0)
347
+ leaf_count = max(1, math.ceil(max_index) + 1)
348
+ for name, idx in positions_index.items():
349
+ level = levels.get(name, 0)
350
+ x = options.padding + idx * (options.node_width + options.h_gap)
351
+ y = options.padding + level * (options.node_height + options.v_gap)
352
+ positions[name] = (x, y)
353
+
354
+ width = (
355
+ options.padding * 2
356
+ + leaf_count * options.node_width
357
+ + max(0, leaf_count - 1) * options.h_gap
358
+ )
359
+ max_level = max(levels.values(), default=0)
360
+ height = (
361
+ options.padding * 2
362
+ + (max_level + 1) * options.node_height
363
+ + max(0, max_level) * options.v_gap
364
+ )
365
+ return positions, width, height
366
+
367
+
368
+ def _layout_nodeset(edges: list[Edge], node_types: dict[str, str]) -> set[str]:
369
+ nodes = set(node_types.keys())
370
+ for edge in edges:
371
+ nodes.add(edge.left)
372
+ nodes.add(edge.right)
373
+ return nodes
374
+
375
+
376
+ def _build_children_maps(
377
+ edges: list[Edge], nodes: set[str]
378
+ ) -> tuple[dict[str, list[str]], dict[str, int]]:
379
+ children: dict[str, list[str]] = {name: [] for name in nodes}
380
+ incoming: dict[str, int] = {name: 0 for name in nodes}
381
+ for edge in edges:
382
+ children[edge.left].append(edge.right)
383
+ incoming[edge.right] = incoming.get(edge.right, 0) + 1
384
+ return children, incoming
385
+
386
+
387
+ def _sort_key_for_nodes(node_types: dict[str, str]) -> Callable[[str], tuple[int, str]]:
388
+ type_order = {t: i for i, t in enumerate(_TYPE_ORDER)}
389
+
390
+ def sort_key(name: str) -> tuple[int, str]:
391
+ return (type_order.get(node_types.get(name, "other"), 99), name.lower())
392
+
393
+ return sort_key
394
+
395
+
396
+ def _sort_children(children: dict[str, list[str]], sort_key) -> None:
397
+ for _parent, child_list in children.items():
398
+ child_list.sort(key=sort_key)
399
+
400
+
401
+ def _resolve_roots(
402
+ nodes: set[str],
403
+ incoming: dict[str, int],
404
+ node_types: dict[str, str],
405
+ sort_key,
406
+ ) -> list[str]:
407
+ gateways = [n for n, t in node_types.items() if t == "gateway"]
408
+ roots = gateways if gateways else [n for n in nodes if incoming.get(n, 0) == 0]
409
+ if not roots:
410
+ roots = list(nodes)
411
+ return sorted(roots, key=sort_key)
412
+
413
+
414
+ def _layout_positions(
415
+ nodes: set[str],
416
+ children: dict[str, list[str]],
417
+ *,
418
+ roots: list[str],
419
+ sort_key,
420
+ ) -> tuple[dict[str, float], dict[str, int]]:
421
+ levels: dict[str, int] = {}
422
+ positions_index: dict[str, float] = {}
423
+ visited: set[str] = set()
424
+ cursor = 0
425
+
426
+ def dfs(node: str, level: int) -> float:
427
+ nonlocal cursor
428
+ if node in positions_index:
429
+ return positions_index[node]
430
+ visited.add(node)
431
+ levels[node] = min(levels.get(node, level), level)
432
+ child_list = children.get(node, [])
433
+ if not child_list:
434
+ idx = float(cursor)
435
+ cursor += 1
436
+ positions_index[node] = idx
437
+ return idx
438
+ child_indices: list[float] = []
439
+ for child in child_list:
440
+ if child in visited:
441
+ child_indices.append(positions_index.get(child, float(cursor)))
442
+ continue
443
+ child_indices.append(dfs(child, level + 1))
444
+ if not child_indices:
445
+ idx = float(cursor)
446
+ cursor += 1
447
+ positions_index[node] = idx
448
+ return idx
449
+ idx = sum(child_indices) / len(child_indices)
450
+ positions_index[node] = idx
451
+ return idx
452
+
453
+ for root in roots:
454
+ dfs(root, 0)
455
+ for node in sorted(nodes, key=sort_key):
456
+ if node not in positions_index:
457
+ dfs(node, 0)
458
+ return positions_index, levels
459
+
460
+
461
+ def _tree_layout_indices(
462
+ edges: list[Edge], node_types: dict[str, str]
463
+ ) -> tuple[dict[str, float], dict[str, int]]:
464
+ nodes = _layout_nodeset(edges, node_types)
465
+ children, incoming = _build_children_maps(edges, nodes)
466
+ sort_key = _sort_key_for_nodes(node_types)
467
+ _sort_children(children, sort_key)
468
+ roots = _resolve_roots(nodes, incoming, node_types, sort_key)
469
+ return _layout_positions(nodes, children, roots=roots, sort_key=sort_key)
470
+
471
+
472
+ def render_svg(
473
+ edges: list[Edge],
474
+ *,
475
+ node_types: dict[str, str],
476
+ node_data: dict[str, dict[str, str]] | None = None,
477
+ options: SvgOptions | None = None,
478
+ theme: SvgTheme = DEFAULT_THEME,
479
+ ) -> str:
480
+ options = options or SvgOptions()
481
+ icons = _load_icons()
482
+ positions, width, height = _layout_nodes(edges, node_types, options)
483
+ out_width = options.width or width
484
+ out_height = options.height or height
485
+
486
+ lines = [
487
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
488
+ f'viewBox="0 0 {width} {height}">',
489
+ svg_defs("", theme),
490
+ (
491
+ "<style>text{font-family:Arial,Helvetica,sans-serif;font-size:"
492
+ f"{options.font_size}px;"
493
+ "}</style>"
494
+ ),
495
+ ]
496
+
497
+ node_port_labels, node_port_prefix = _render_svg_edges(
498
+ lines, edges, positions, node_types, options
499
+ )
500
+ _render_svg_nodes(
501
+ lines,
502
+ positions,
503
+ node_types,
504
+ node_port_labels,
505
+ node_port_prefix,
506
+ icons,
507
+ options,
508
+ node_data,
509
+ )
510
+
511
+ lines.append("</svg>")
512
+ return "\n".join(lines) + "\n"
513
+
514
+
515
+ def _render_svg_edges(
516
+ lines: list[str],
517
+ edges: list[Edge],
518
+ positions: dict[str, tuple[float, float]],
519
+ node_types: dict[str, str],
520
+ options: SvgOptions,
521
+ ) -> tuple[dict[str, str], dict[str, str]]:
522
+ node_port_labels: dict[str, str] = {}
523
+ node_port_prefix: dict[str, str] = {}
524
+ for edge in edges:
525
+ _record_edge_labels(edge, node_types, node_port_labels, node_port_prefix)
526
+ for edge in sorted(edges, key=lambda item: item.poe):
527
+ if edge.left not in positions or edge.right not in positions:
528
+ continue
529
+ src_x, src_y = positions[edge.left]
530
+ dst_x, dst_y = positions[edge.right]
531
+ src_cx = src_x + options.node_width / 2
532
+ dst_cx = dst_x + options.node_width / 2
533
+ src_bottom = src_y + options.node_height
534
+ dst_top = dst_y
535
+ mid_y = (src_bottom + dst_top) / 2
536
+ color = "url(#link-poe)" if edge.poe else "url(#link-standard)"
537
+ 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}"
543
+ )
544
+ 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
+ )
556
+ 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
+ )
563
+ return node_port_labels, node_port_prefix
564
+
565
+
566
+ def _record_edge_labels(
567
+ edge: Edge,
568
+ node_types: dict[str, str],
569
+ node_port_labels: dict[str, str],
570
+ node_port_prefix: dict[str, str],
571
+ ) -> None:
572
+ if not edge.label:
573
+ return
574
+ label_text = _compact_edge_label(edge.label, left_node=edge.left, right_node=edge.right)
575
+ left_type = node_types.get(edge.left, "other")
576
+ right_type = node_types.get(edge.right, "other")
577
+ client_node = None
578
+ upstream_node = None
579
+ if left_type == "client" and right_type != "client":
580
+ client_node = edge.left
581
+ upstream_node = edge.right
582
+ elif right_type == "client" and left_type != "client":
583
+ client_node = edge.right
584
+ upstream_node = edge.left
585
+ if client_node and upstream_node:
586
+ if "<->" not in label_text:
587
+ upstream_part = edge.label.split("<->", 1)[0].strip()
588
+ port_text = _extract_port_text(upstream_part) or label_text
589
+ upstream_name = _extract_device_name(upstream_part) or upstream_node
590
+ node_port_labels.setdefault(client_node, f"{upstream_name}: {port_text}")
591
+ node_port_prefix.setdefault(client_node, upstream_name)
592
+ return
593
+ upstream_part = edge.label.split("<->", 1)[0].strip()
594
+ upstream_name = _extract_device_name(upstream_part) or edge.left
595
+ if label_text.lower().startswith("port "):
596
+ label_text = f"{upstream_name} {label_text}"
597
+ node_port_labels.setdefault(edge.right, label_text)
598
+ node_port_prefix.setdefault(edge.right, upstream_name)
599
+
600
+
601
+ def _render_svg_nodes(
602
+ lines: list[str],
603
+ positions: dict[str, tuple[float, float]],
604
+ node_types: dict[str, str],
605
+ node_port_labels: dict[str, str],
606
+ node_port_prefix: dict[str, str],
607
+ icons: dict[str, str],
608
+ options: SvgOptions,
609
+ node_data: dict[str, dict[str, str]] | None,
610
+ ) -> None:
611
+ for name, (x, y) in positions.items():
612
+ node_type = node_types.get(name, "other")
613
+ fill, stroke = _TYPE_COLORS.get(node_type, _TYPE_COLORS["other"])
614
+ fill = f"url(#node-{node_type})"
615
+ group_attrs = _svg_node_group_attrs(node_data, name, node_type)
616
+ lines.append(f"<g{group_attrs}>")
617
+ lines.append(f"<title>{_escape_text(name)}</title>")
618
+ lines.append(
619
+ f'<rect x="{x}" y="{y}" width="{options.node_width}" height="{options.node_height}" '
620
+ 'fill="transparent" pointer-events="all" class="node-hitbox"/>'
621
+ )
622
+ lines.append(
623
+ f'<rect x="{x}" y="{y}" width="{options.node_width}" height="{options.node_height}" '
624
+ f'rx="6" ry="6" fill="{fill}" stroke="{stroke}" stroke-width="1"/>'
625
+ )
626
+ icon_href = icons.get(node_type, icons.get("other"))
627
+ if icon_href:
628
+ icon_x = x + 8
629
+ icon_y = y + (options.node_height - options.icon_size) / 2
630
+ lines.append(
631
+ f'<image href="{icon_href}" x="{icon_x}" y="{icon_y}" '
632
+ f'width="{options.icon_size}" height="{options.icon_size}"/>'
633
+ )
634
+ text_x = icon_x + options.icon_size + 6
635
+ else:
636
+ text_x = x + 10
637
+ port_label = node_port_labels.get(name)
638
+ if port_label:
639
+ text_y = y + options.node_height - 6
640
+ else:
641
+ text_y = y + options.node_height / 2 + options.font_size / 2 - 2
642
+ safe_name = _escape_text(name)
643
+ if port_label:
644
+ font_size = max(options.font_size - 2, 8)
645
+ line_height = font_size + 2
646
+ port_y = y + font_size + 4
647
+ wrapped = _wrap_text(port_label)
648
+ lines.append(
649
+ f'<text x="{text_x}" y="{port_y}" class="node-port" '
650
+ f'text-anchor="start" fill="#555" font-size="{font_size}">'
651
+ )
652
+ for idx, line in enumerate(wrapped):
653
+ dy = 0 if idx == 0 else line_height
654
+ lines.append(f'<tspan x="{text_x}" dy="{dy}">{_escape_text(line)}</tspan>')
655
+ lines.append("</text>")
656
+ lines.append(
657
+ f'<text x="{text_x}" y="{text_y}" fill="#1f1f1f" text-anchor="start">{safe_name}</text>'
658
+ )
659
+ lines.append("</g>")
660
+
661
+
662
+ def _svg_node_group_attrs(
663
+ node_data: dict[str, dict[str, str]] | None,
664
+ name: str,
665
+ node_type: str,
666
+ ) -> str:
667
+ attrs: dict[str, str] = {
668
+ "class": "unm-node",
669
+ "data-node-id": name,
670
+ "data-node-type": node_type,
671
+ }
672
+ if node_data and (extra := node_data.get(name)):
673
+ for key, value in extra.items():
674
+ if key == "class":
675
+ attrs["class"] = f"{attrs['class']} {value}".strip()
676
+ else:
677
+ attrs[key] = value
678
+ rendered = [f' {key}="{_escape_attr(value, quote=True)}"' for key, value in attrs.items()]
679
+ 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"