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
@@ -0,0 +1,1196 @@
1
+ """Isometric SVG rendering for network diagrams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from html import escape as _escape_html
8
+
9
+ from ..model.topology import Edge, WanInfo
10
+ from .svg import (
11
+ SvgOptions,
12
+ _build_node_to_group_map,
13
+ _edge_opacity,
14
+ _render_vlan_endpoint_markers,
15
+ _resolve_group_order,
16
+ _svg_node_group_attrs,
17
+ _svg_style_block,
18
+ _tree_layout_indices,
19
+ _vlan_data_attrs,
20
+ )
21
+ from .svg_icons import _TYPE_COLORS, _build_decal_colors, _load_isometric_icons
22
+ from .svg_labels import (
23
+ _build_wan_label_lines,
24
+ _compact_edge_label,
25
+ _escape_text,
26
+ _extract_device_name,
27
+ _extract_port_text,
28
+ _format_port_label_lines,
29
+ _shorten_prefix,
30
+ )
31
+ from .svg_theme import DEFAULT_THEME, SvgTheme, svg_defs
32
+
33
+ # Isometric layout constants (module-level for discoverability)
34
+ _ISO_NW_PADDING = 300 # North-west padding for iso layout
35
+ _ISO_VIEWPORT_EXPAND = 400 # Viewport expansion around content
36
+ _ISO_GRID_EXTENT_PAD = 36 # Grid extent padding beyond content
37
+ _ISO_GROUP_LABEL_SIZE = 48 # Font size for group boundary labels
38
+ _ISO_PERSPECTIVE_ANGLE = 30 # Isometric perspective angle in degrees
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class IsoLayout:
43
+ iso_angle: float
44
+ tile_width: float
45
+ tile_height: float
46
+ step_width: float
47
+ step_height: float
48
+ grid_spacing_x: int
49
+ grid_spacing_y: int
50
+ padding: float
51
+ tile_y_offset: float
52
+ extra_pad: float
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class IsoLayoutPositions:
57
+ layout: IsoLayout
58
+ grid_positions: dict[str, tuple[float, float]]
59
+ positions: dict[str, tuple[float, float]]
60
+ width: float
61
+ height: float
62
+ offset_x: float
63
+ offset_y: float
64
+
65
+
66
+ def _iso_layout(options: SvgOptions) -> IsoLayout:
67
+ tile_width = options.node_width * 1.5
68
+ iso_angle = math.radians(30.0)
69
+ tile_height = tile_width * math.tan(iso_angle)
70
+ step_width = tile_width
71
+ step_height = tile_height
72
+ grid_spacing_x = max(2, 1 + int(round(options.h_gap / max(tile_width, 1))))
73
+ grid_spacing_y = max(2, 1 + int(round(options.v_gap / max(tile_height, 1))))
74
+ padding = float(options.padding)
75
+ tile_y_offset = tile_height / 2
76
+ extra_pad = max(12.0, tile_width * 0.35)
77
+ return IsoLayout(
78
+ iso_angle=iso_angle,
79
+ tile_width=tile_width,
80
+ tile_height=tile_height,
81
+ step_width=step_width,
82
+ step_height=step_height,
83
+ grid_spacing_x=grid_spacing_x,
84
+ grid_spacing_y=grid_spacing_y,
85
+ padding=padding,
86
+ tile_y_offset=tile_y_offset,
87
+ extra_pad=extra_pad,
88
+ )
89
+
90
+
91
+ def _iso_tile_points(
92
+ center_x: float, center_y: float, width: float, height: float
93
+ ) -> list[tuple[float, float]]:
94
+ return [
95
+ (center_x, center_y - height / 2),
96
+ (center_x + width / 2, center_y),
97
+ (center_x, center_y + height / 2),
98
+ (center_x - width / 2, center_y),
99
+ ]
100
+
101
+
102
+ def _points_to_svg(points: list[tuple[float, float]]) -> str:
103
+ return " ".join(f"{px},{py}" for px, py in points)
104
+
105
+
106
+ def _iso_front_text_position(
107
+ top_points: list[tuple[float, float]], tile_width: float, tile_height: float
108
+ ) -> tuple[float, float, float]:
109
+ left_edge_top = top_points[0]
110
+ left_edge_bottom = top_points[3]
111
+ edge_mid_x = (left_edge_top[0] + left_edge_bottom[0]) / 2
112
+ edge_mid_y = (left_edge_top[1] + left_edge_bottom[1]) / 2
113
+ center_x = sum(px for px, _py in top_points) / len(top_points)
114
+ center_y = sum(py for _px, py in top_points) / len(top_points)
115
+ normal_x = center_x - edge_mid_x
116
+ normal_y = center_y - edge_mid_y
117
+ normal_len = math.hypot(normal_x, normal_y) or 1.0
118
+ normal_x /= normal_len
119
+ normal_y /= normal_len
120
+ inset = tile_height * 0.27
121
+ text_x = edge_mid_x + normal_x * inset + tile_width * 0.02
122
+ text_y = edge_mid_y + normal_y * inset + tile_height * 0.33
123
+ edge_dx = left_edge_bottom[0] - left_edge_top[0]
124
+ edge_dy = left_edge_bottom[1] - left_edge_top[1]
125
+ edge_len = math.hypot(edge_dx, edge_dy) or 1.0
126
+ edge_dx /= edge_len
127
+ edge_dy /= edge_len
128
+ slide = tile_height * 0.32
129
+ text_x += edge_dx * slide
130
+ text_y += edge_dy * slide
131
+ name_edge_left = top_points[3]
132
+ name_edge_right = top_points[2]
133
+ angle = math.degrees(
134
+ math.atan2(
135
+ name_edge_right[1] - name_edge_left[1],
136
+ name_edge_right[0] - name_edge_left[0],
137
+ )
138
+ )
139
+ return text_x, text_y, angle
140
+
141
+
142
+ def _render_iso_text(
143
+ lines: list[str],
144
+ *,
145
+ text_x: float,
146
+ text_y: float,
147
+ angle: float,
148
+ text_lines: list[str],
149
+ font_size: int,
150
+ fill: str,
151
+ ) -> None:
152
+ line_height = font_size + 2
153
+ start_y = text_y - (len(text_lines) - 1) * line_height / 2
154
+ text_transform = (
155
+ f"translate({text_x} {start_y}) rotate({angle}) skewX(30) translate({-text_x} {-start_y})"
156
+ )
157
+ lines.append(
158
+ f'<text x="{text_x}" y="{start_y}" text-anchor="middle" fill="{fill}" '
159
+ f'font-size="{font_size}" font-style="normal" '
160
+ f'transform="{text_transform}">'
161
+ )
162
+ for idx, line in enumerate(text_lines):
163
+ dy = 0 if idx == 0 else line_height
164
+ lines.append(f'<tspan x="{text_x}" dy="{dy}">{_escape_text(line)}</tspan>')
165
+ lines.append("</text>")
166
+
167
+
168
+ def _iso_name_label_position(
169
+ top_points: list[tuple[float, float]],
170
+ *,
171
+ tile_width: float,
172
+ tile_height: float,
173
+ font_size: int,
174
+ ) -> tuple[float, float, float]:
175
+ name_edge_left = top_points[3]
176
+ name_edge_right = top_points[2]
177
+ name_mid_x = (name_edge_left[0] + name_edge_right[0]) / 2
178
+ name_mid_y = (name_edge_left[1] + name_edge_right[1]) / 2
179
+ name_center_x = sum(px for px, _py in top_points) / len(top_points)
180
+ name_center_y = sum(py for _px, py in top_points) / len(top_points)
181
+ name_normal_x = name_center_x - name_mid_x
182
+ name_normal_y = name_center_y - name_mid_y
183
+ name_normal_len = math.hypot(name_normal_x, name_normal_y) or 1.0
184
+ name_normal_x /= name_normal_len
185
+ name_normal_y /= name_normal_len
186
+ name_inset = tile_height * 0.13
187
+ name_x = name_mid_x + name_normal_x * name_inset - tile_width * 0.08
188
+ name_y = name_mid_y + name_normal_y * name_inset + font_size - tile_height * 0.05
189
+ name_angle = math.degrees(
190
+ math.atan2(
191
+ name_edge_right[1] - name_edge_left[1],
192
+ name_edge_right[0] - name_edge_left[0],
193
+ )
194
+ )
195
+ return name_x, name_y, name_angle
196
+
197
+
198
+ def _iso_project(layout: IsoLayout, gx: float, gy: float) -> tuple[float, float]:
199
+ iso_x = (gx - gy) * (layout.step_width / 2)
200
+ iso_y = (gx + gy) * (layout.step_height / 2)
201
+ return iso_x, iso_y
202
+
203
+
204
+ def _iso_project_center(layout: IsoLayout, gx: float, gy: float) -> tuple[float, float]:
205
+ return _iso_project(layout, gx + 0.5, gy + 0.5)
206
+
207
+
208
+ def _iso_layout_positions(
209
+ edges: list[Edge],
210
+ node_types: dict[str, str],
211
+ options: SvgOptions,
212
+ ) -> IsoLayoutPositions:
213
+ layout = _iso_layout(options)
214
+ positions_index, levels = _tree_layout_indices(edges, node_types)
215
+ grid_positions: dict[str, tuple[float, float]] = {}
216
+ positions: dict[str, tuple[float, float]] = {}
217
+ for name, idx in positions_index.items():
218
+ level = levels.get(name, 0)
219
+ gx = round(idx * layout.grid_spacing_x)
220
+ gy = round(float(level) * layout.grid_spacing_y)
221
+ grid_positions[name] = (float(gx), float(gy))
222
+ iso_x, iso_y = _iso_project_center(layout, float(gx), float(gy))
223
+ positions[name] = (iso_x, iso_y)
224
+ if positions:
225
+ min_x = min(x for x, _ in positions.values())
226
+ min_y = min(y for _, y in positions.values())
227
+ max_x = max(x for x, _ in positions.values())
228
+ max_y = max(y for _, y in positions.values())
229
+ else:
230
+ min_x = min_y = 0.0
231
+ max_x = max_y = 0.0
232
+ offset_x = -min_x + layout.padding + _ISO_NW_PADDING
233
+ offset_y = -min_y + layout.padding + layout.tile_y_offset + _ISO_NW_PADDING
234
+ for name, (x, y) in positions.items():
235
+ positions[name] = (x + offset_x, y + offset_y)
236
+ # Expand viewport to show more of the grid
237
+ viewport_expand = _ISO_VIEWPORT_EXPAND
238
+ width = (
239
+ max_x
240
+ - min_x
241
+ + layout.tile_width
242
+ + layout.padding * 2
243
+ + layout.extra_pad
244
+ + viewport_expand
245
+ + _ISO_NW_PADDING
246
+ )
247
+ height = (
248
+ max_y
249
+ - min_y
250
+ + layout.tile_height
251
+ + layout.padding * 2
252
+ + layout.tile_y_offset
253
+ + layout.extra_pad
254
+ + viewport_expand
255
+ + _ISO_NW_PADDING
256
+ )
257
+ return IsoLayoutPositions(
258
+ layout=layout,
259
+ grid_positions=grid_positions,
260
+ positions=positions,
261
+ width=width,
262
+ height=height,
263
+ offset_x=offset_x,
264
+ offset_y=offset_y,
265
+ )
266
+
267
+
268
+ def _iso_grid_lines(
269
+ grid_positions: dict[str, tuple[float, float]],
270
+ layout: IsoLayout,
271
+ grid_color: str = "#efefef",
272
+ ) -> list[str]:
273
+ if not grid_positions:
274
+ return []
275
+ min_gx = min(gx for gx, _ in grid_positions.values())
276
+ max_gx = max(gx for gx, _ in grid_positions.values())
277
+ min_gy = min(gy for _, gy in grid_positions.values())
278
+ max_gy = max(gy for _, gy in grid_positions.values())
279
+ gx_start = int(math.floor(min_gx)) - _ISO_GRID_EXTENT_PAD
280
+ gx_end = int(math.ceil(max_gx)) + _ISO_GRID_EXTENT_PAD
281
+ gy_start = int(math.floor(min_gy)) - _ISO_GRID_EXTENT_PAD
282
+ gy_end = int(math.ceil(max_gy)) + _ISO_GRID_EXTENT_PAD
283
+ grid_lines: list[str] = []
284
+ for gx in range(gx_start, gx_end + 1):
285
+ x1, y1 = _iso_project(layout, float(gx), float(gy_start))
286
+ x2, y2 = _iso_project(layout, float(gx), float(gy_end))
287
+ x1 += layout.padding
288
+ y1 += layout.padding
289
+ x2 += layout.padding
290
+ y2 += layout.padding
291
+ grid_lines.append(
292
+ f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{grid_color}" stroke-width="0.6"/>'
293
+ )
294
+ for gy in range(gy_start, gy_end + 1):
295
+ x1, y1 = _iso_project(layout, float(gx_start), float(gy))
296
+ x2, y2 = _iso_project(layout, float(gx_end), float(gy))
297
+ x1 += layout.padding
298
+ y1 += layout.padding
299
+ x2 += layout.padding
300
+ y2 += layout.padding
301
+ grid_lines.append(
302
+ f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="{grid_color}" stroke-width="0.6"/>'
303
+ )
304
+ return grid_lines
305
+
306
+
307
+ def _iso_front_anchor(
308
+ layout: IsoLayout,
309
+ *,
310
+ gx: float,
311
+ gy: float,
312
+ offset_x: float,
313
+ offset_y: float,
314
+ ) -> tuple[float, float]:
315
+ iso_x, iso_y = _iso_project_center(layout, gx, gy)
316
+ cx = iso_x + offset_x + layout.tile_width / 2
317
+ cy = iso_y + offset_y + layout.tile_height / 2
318
+ return cx, cy
319
+
320
+
321
+ def _render_iso_vlan_striped_edge(
322
+ lines: list[str],
323
+ path: str,
324
+ vlans: tuple[int, ...],
325
+ theme: SvgTheme,
326
+ base_width: int,
327
+ is_wireless: bool,
328
+ extra_attrs: str,
329
+ opacity: float = 1.0,
330
+ ) -> None:
331
+ """Render an isometric edge with striped VLAN colors and glow effect."""
332
+ if not vlans:
333
+ return
334
+ num_vlans = len(vlans)
335
+ segment_len = 16 # Slightly larger for isometric view
336
+ total_pattern = segment_len * num_vlans
337
+ gap_len = total_pattern - segment_len
338
+ opacity_attr = f' opacity="{opacity}"' if opacity < 1.0 else ""
339
+
340
+ # Render glow layer behind the edge
341
+ glow_color = theme.vlan_color(vlans[0])
342
+ glow_width = base_width * 3
343
+ glow_opacity = 0.25 * opacity # Scale glow with edge opacity
344
+ lines.append(
345
+ f'<path d="{path}" stroke="{glow_color}" stroke-width="{glow_width}" '
346
+ f'fill="none" stroke-linecap="round" stroke-linejoin="round" '
347
+ f'opacity="{glow_opacity}" filter="url(#iso-edge-glow)" {extra_attrs}/>'
348
+ )
349
+
350
+ for i, vlan_id in enumerate(vlans):
351
+ color = theme.vlan_color(vlan_id)
352
+ offset = -i * segment_len
353
+ dash = f'stroke-dasharray="{segment_len} {gap_len}"'
354
+ if is_wireless:
355
+ dash = f'stroke-dasharray="6 3 6 {gap_len + 1}"'
356
+ lines.append(
357
+ f'<path d="{path}" stroke="{color}" stroke-width="{base_width}" '
358
+ f'fill="none" stroke-linecap="round" stroke-linejoin="round" '
359
+ f'{dash} stroke-dashoffset="{offset}"{opacity_attr} {extra_attrs}/>'
360
+ )
361
+
362
+
363
+ def _render_iso_poe_icon(
364
+ lines: list[str],
365
+ layout: IsoLayout,
366
+ offset_x: float,
367
+ offset_y: float,
368
+ src_gx: float,
369
+ src_gy: float,
370
+ dst_gx: float,
371
+ dst_gy: float,
372
+ src_cx: float,
373
+ src_cy: float,
374
+ dst_cx: float,
375
+ dst_cy: float,
376
+ theme: SvgTheme,
377
+ ) -> None:
378
+ """Render PoE icon on an edge path."""
379
+ poe_size = 30
380
+ dx = dst_gx - src_gx
381
+ dy = dst_gy - src_gy
382
+ if dx == 0 or dy == 0:
383
+ seg_start_x, seg_start_y = src_cx, src_cy
384
+ else:
385
+ elbow_cx, elbow_cy = _iso_front_anchor(
386
+ layout, gx=dst_gx, gy=src_gy, offset_x=offset_x, offset_y=offset_y
387
+ )
388
+ seg_start_x, seg_start_y = elbow_cx, elbow_cy
389
+ t = 0.6
390
+ icon_center_x = seg_start_x + t * (dst_cx - seg_start_x)
391
+ icon_center_y = seg_start_y + t * (dst_cy - seg_start_y)
392
+ icon_x = icon_center_x - poe_size / 2
393
+ icon_y = icon_center_y - poe_size / 2
394
+ lines.append(
395
+ f'<use href="#iso-poe-bolt" x="{icon_x}" y="{icon_y}" '
396
+ f'width="{poe_size}" height="{poe_size}" '
397
+ f'fill="{theme.poe_fill}" stroke="{theme.poe_stroke}" stroke-width="1"/>'
398
+ )
399
+
400
+
401
+ def _render_iso_standard_edge(
402
+ lines: list[str],
403
+ path: str,
404
+ edge: Edge,
405
+ width_px: int,
406
+ base_attrs: str,
407
+ opacity_attr: str,
408
+ ) -> None:
409
+ """Render a standard (non-VLAN) edge path."""
410
+ color = "url(#iso-link-poe)" if edge.poe else "url(#iso-link-standard)"
411
+ dash = ' stroke-dasharray="8 6"' if edge.wireless else ""
412
+ lines.append(
413
+ f'<path d="{path}" stroke="{color}" stroke-width="{width_px}" '
414
+ f'fill="none" stroke-linecap="round" stroke-linejoin="round"{dash}{opacity_attr} '
415
+ f"{base_attrs}/>"
416
+ )
417
+
418
+
419
+ def _render_iso_edges(
420
+ lines: list[str],
421
+ edges: list[Edge],
422
+ *,
423
+ positions: dict[str, tuple[float, float]],
424
+ grid_positions: dict[str, tuple[float, float]],
425
+ node_types: dict[str, str],
426
+ layout: IsoLayout,
427
+ theme: SvgTheme,
428
+ offset_x: float,
429
+ offset_y: float,
430
+ node_port_labels: dict[str, str],
431
+ node_port_prefix: dict[str, str],
432
+ max_vlan_colors: int | None = None,
433
+ ) -> None:
434
+ for edge in edges:
435
+ _record_iso_edge_label(edge, node_types, node_port_labels, node_port_prefix)
436
+ for edge in sorted(edges, key=lambda item: item.poe):
437
+ if edge.left not in positions or edge.right not in positions:
438
+ continue
439
+ src_grid = grid_positions.get(edge.left)
440
+ dst_grid = grid_positions.get(edge.right)
441
+ if not src_grid or not dst_grid:
442
+ continue
443
+ width_px = 5 if edge.poe else 4
444
+ src_gx, src_gy = float(src_grid[0]), float(src_grid[1])
445
+ dst_gx, dst_gy = float(dst_grid[0]), float(dst_grid[1])
446
+ src_cx, src_cy = _iso_front_anchor(
447
+ layout, gx=src_gx, gy=src_gy, offset_x=offset_x, offset_y=offset_y
448
+ )
449
+ dst_cx, dst_cy = _iso_front_anchor(
450
+ layout, gx=dst_gx, gy=dst_gy, offset_x=offset_x, offset_y=offset_y
451
+ )
452
+ path_cmds = _iso_edge_path(
453
+ layout,
454
+ offset_x,
455
+ offset_y,
456
+ src_gx,
457
+ src_gy,
458
+ dst_gx,
459
+ dst_gy,
460
+ src_cx,
461
+ src_cy,
462
+ dst_cx,
463
+ dst_cy,
464
+ )
465
+ path = " ".join(path_cmds)
466
+ left_attr = _escape_html(edge.left, quote=True)
467
+ right_attr = _escape_html(edge.right, quote=True)
468
+ vlan_attrs = _vlan_data_attrs(edge)
469
+ base_attrs = f'data-edge-left="{left_attr}" data-edge-right="{right_attr}"'
470
+ if vlan_attrs:
471
+ base_attrs = f"{base_attrs} {vlan_attrs}"
472
+
473
+ display_vlans = edge.active_vlans
474
+ if max_vlan_colors and len(display_vlans) > max_vlan_colors:
475
+ display_vlans = display_vlans[:max_vlan_colors]
476
+
477
+ opacity = _edge_opacity(node_types, edge)
478
+ opacity_attr = f' opacity="{opacity}"' if opacity < 1.0 else ""
479
+
480
+ if display_vlans:
481
+ _render_iso_vlan_striped_edge(
482
+ lines, path, display_vlans, theme, width_px, edge.wireless, base_attrs, opacity
483
+ )
484
+ marker_x = dst_cx + layout.tile_width * 0.3
485
+ marker_y = dst_cy - layout.tile_height * 0.2
486
+ _render_vlan_endpoint_markers(lines, marker_x, marker_y, display_vlans, theme)
487
+ else:
488
+ _render_iso_standard_edge(lines, path, edge, width_px, base_attrs, opacity_attr)
489
+
490
+ if edge.poe:
491
+ _render_iso_poe_icon(
492
+ lines,
493
+ layout,
494
+ offset_x,
495
+ offset_y,
496
+ src_gx,
497
+ src_gy,
498
+ dst_gx,
499
+ dst_gy,
500
+ src_cx,
501
+ src_cy,
502
+ dst_cx,
503
+ dst_cy,
504
+ theme,
505
+ )
506
+
507
+
508
+ def _iso_edge_path(
509
+ layout: IsoLayout,
510
+ offset_x: float,
511
+ offset_y: float,
512
+ src_gx: float,
513
+ src_gy: float,
514
+ dst_gx: float,
515
+ dst_gy: float,
516
+ src_cx: float,
517
+ src_cy: float,
518
+ dst_cx: float,
519
+ dst_cy: float,
520
+ ) -> list[str]:
521
+ dx = dst_gx - src_gx
522
+ dy = dst_gy - src_gy
523
+ if dx == 0 or dy == 0:
524
+ return [f"M {src_cx} {src_cy}", f"L {dst_cx} {dst_cy}"]
525
+ elbow_gx, elbow_gy = dst_gx, src_gy
526
+ elbow_cx, elbow_cy = _iso_front_anchor(
527
+ layout,
528
+ gx=elbow_gx,
529
+ gy=elbow_gy,
530
+ offset_x=offset_x,
531
+ offset_y=offset_y,
532
+ )
533
+ return [
534
+ f"M {src_cx} {src_cy}",
535
+ f"L {elbow_cx} {elbow_cy}",
536
+ f"L {dst_cx} {dst_cy}",
537
+ ]
538
+
539
+
540
+ def _record_iso_edge_label(
541
+ edge: Edge,
542
+ node_types: dict[str, str],
543
+ node_port_labels: dict[str, str],
544
+ node_port_prefix: dict[str, str],
545
+ ) -> None:
546
+ if not edge.label:
547
+ return
548
+ label_text = _compact_edge_label(edge.label, left_node=edge.left, right_node=edge.right)
549
+ left_type = node_types.get(edge.left, "other")
550
+ right_type = node_types.get(edge.right, "other")
551
+ client_node = None
552
+ upstream_node = None
553
+ if left_type == "client" and right_type != "client":
554
+ client_node = edge.left
555
+ upstream_node = edge.right
556
+ elif right_type == "client" and left_type != "client":
557
+ client_node = edge.right
558
+ upstream_node = edge.left
559
+ if client_node and upstream_node:
560
+ if "<->" not in label_text:
561
+ upstream_part = edge.label.split("<->", 1)[0].strip()
562
+ port_text = _extract_port_text(upstream_part) or label_text
563
+ node_port_labels.setdefault(client_node, f"{upstream_node}: {port_text}")
564
+ node_port_prefix.setdefault(client_node, _shorten_prefix(upstream_node))
565
+ return
566
+ upstream_part = edge.label.split("<->", 1)[0].strip()
567
+ upstream_name = _extract_device_name(upstream_part) or edge.left
568
+ if label_text.lower().startswith("port "):
569
+ label_text = f"{upstream_name} {label_text}"
570
+ node_port_labels.setdefault(edge.right, label_text)
571
+ node_port_prefix.setdefault(edge.right, _shorten_prefix(edge.left))
572
+
573
+
574
+ def _iso_node_polygons(
575
+ x: float,
576
+ y: float,
577
+ tile_w: float,
578
+ tile_h: float,
579
+ node_depth: float,
580
+ ) -> tuple[list[tuple[float, float]], list[tuple[float, float]], list[tuple[float, float]]]:
581
+ top = [
582
+ (x + tile_w / 2, y),
583
+ (x + tile_w, y + tile_h / 2),
584
+ (x + tile_w / 2, y + tile_h),
585
+ (x, y + tile_h / 2),
586
+ ]
587
+ left = [
588
+ (x, y + tile_h / 2),
589
+ (x + tile_w / 2, y + tile_h),
590
+ (x + tile_w / 2, y + tile_h + node_depth),
591
+ (x, y + tile_h / 2 + node_depth),
592
+ ]
593
+ right = [
594
+ (x + tile_w, y + tile_h / 2),
595
+ (x + tile_w / 2, y + tile_h),
596
+ (x + tile_w / 2, y + tile_h + node_depth),
597
+ (x + tile_w, y + tile_h / 2 + node_depth),
598
+ ]
599
+ return top, left, right
600
+
601
+
602
+ def _iso_render_faces(
603
+ lines: list[str],
604
+ *,
605
+ top: list[tuple[float, float]],
606
+ left: list[tuple[float, float]],
607
+ right: list[tuple[float, float]],
608
+ fill: str,
609
+ stroke: str,
610
+ left_fill: str,
611
+ right_fill: str,
612
+ node_depth: float,
613
+ ) -> None:
614
+ if node_depth > 0:
615
+ lines.append(
616
+ f'<polygon points="{" ".join(f"{px},{py}" for px, py in left)}" '
617
+ f'fill="{left_fill}" stroke="{stroke}" stroke-width="1"/>'
618
+ )
619
+ lines.append(
620
+ f'<polygon points="{" ".join(f"{px},{py}" for px, py in right)}" '
621
+ f'fill="{right_fill}" stroke="{stroke}" stroke-width="1"/>'
622
+ )
623
+ lines.append(
624
+ f'<polygon points="{" ".join(f"{px},{py}" for px, py in top)}" '
625
+ f'fill="{fill}" stroke="{stroke}" stroke-width="1"/>'
626
+ )
627
+
628
+
629
+ def _render_iso_port_label(
630
+ lines: list[str],
631
+ *,
632
+ port_label: str,
633
+ node_type: str,
634
+ prefix: str,
635
+ center_x: float,
636
+ center_y: float,
637
+ tile_w: float,
638
+ tile_h: float,
639
+ fill: str,
640
+ stroke: str,
641
+ left_fill: str,
642
+ right_fill: str,
643
+ font_size: int,
644
+ ) -> tuple[float, float]:
645
+ tile_width = tile_w
646
+ tile_height = tile_h
647
+ stack_depth = tile_h / 2
648
+ label_center_x = center_x
649
+ label_center_y = center_y - stack_depth
650
+ top_points = _iso_tile_points(label_center_x, label_center_y, tile_width, tile_height)
651
+ tile_points = _points_to_svg(top_points)
652
+ bottom_points = [(px, py + stack_depth) for px, py in top_points]
653
+ right_face = [
654
+ top_points[1],
655
+ top_points[2],
656
+ bottom_points[2],
657
+ bottom_points[1],
658
+ ]
659
+ left_face = [
660
+ top_points[3],
661
+ top_points[2],
662
+ bottom_points[2],
663
+ bottom_points[3],
664
+ ]
665
+ left_points = " ".join(f"{px},{py}" for px, py in left_face)
666
+ right_points = " ".join(f"{px},{py}" for px, py in right_face)
667
+ lines.append(
668
+ f'<polygon class="label-tile-side" points="{left_points}" '
669
+ f'fill="{left_fill}" stroke="{stroke}" stroke-width="1"/>'
670
+ )
671
+ lines.append(
672
+ f'<polygon class="label-tile-side" points="{right_points}" '
673
+ f'fill="{right_fill}" stroke="{stroke}" stroke-width="1"/>'
674
+ )
675
+ lines.append(
676
+ f'<polygon class="label-tile" points="{tile_points}" '
677
+ f'fill="{fill}" stroke="{stroke}" stroke-width="1"/>'
678
+ )
679
+ left_edge_top = top_points[0]
680
+ left_edge_bottom = top_points[3]
681
+ edge_len = math.hypot(
682
+ left_edge_bottom[0] - left_edge_top[0],
683
+ left_edge_bottom[1] - left_edge_top[1],
684
+ )
685
+ max_chars = max(6, int((edge_len * 0.85) / (font_size * 0.6)))
686
+ front_lines = _format_port_label_lines(
687
+ port_label,
688
+ node_type=node_type,
689
+ prefix=prefix,
690
+ max_chars=max_chars,
691
+ )
692
+ if front_lines:
693
+ text_x, text_y, edge_angle = _iso_front_text_position(top_points, tile_w, tile_h)
694
+ _render_iso_text(
695
+ lines,
696
+ text_x=text_x,
697
+ text_y=text_y,
698
+ angle=edge_angle,
699
+ text_lines=front_lines,
700
+ font_size=font_size,
701
+ fill="#555",
702
+ )
703
+ return label_center_x, label_center_y
704
+
705
+
706
+ def _iso_front_face_label_position(
707
+ left_face: list[tuple[float, float]],
708
+ tile_height: float,
709
+ font_size: int,
710
+ ) -> tuple[float, float, float]:
711
+ """Position label on the front (left) face of an isometric node."""
712
+ # left_face points: [top-left, top-right, bottom-right, bottom-left]
713
+ # We want to center the text on this face
714
+ top_left = left_face[0]
715
+ top_right = left_face[1]
716
+ bottom_right = left_face[2]
717
+ bottom_left = left_face[3]
718
+
719
+ # Center of the face
720
+ center_x = (top_left[0] + top_right[0] + bottom_right[0] + bottom_left[0]) / 4
721
+ center_y = (top_left[1] + top_right[1] + bottom_right[1] + bottom_left[1]) / 4
722
+
723
+ # Angle follows the top edge of the left face (from top_left to top_right)
724
+ angle = math.degrees(
725
+ math.atan2(
726
+ top_right[1] - top_left[1],
727
+ top_right[0] - top_left[0],
728
+ )
729
+ )
730
+
731
+ # Adjust position for better visual centering on the front face
732
+ label_x = center_x - tile_height * 0.01
733
+ label_y = center_y + font_size * 0.5 + tile_height * 0.04 - 6
734
+
735
+ return label_x, label_y, angle
736
+
737
+
738
+ def _render_iso_node(
739
+ lines: list[str],
740
+ *,
741
+ name: str,
742
+ x: float,
743
+ y: float,
744
+ node_type: str,
745
+ icons: dict[str, str],
746
+ options: SvgOptions,
747
+ port_label: str | None,
748
+ port_prefix: str | None,
749
+ layout: IsoLayout,
750
+ theme: SvgTheme,
751
+ ) -> None:
752
+ fill, stroke = _TYPE_COLORS.get(node_type, _TYPE_COLORS["other"])
753
+ fill = f"url(#iso-node-{node_type})"
754
+ tile_w = layout.tile_width
755
+ tile_h = layout.tile_height
756
+
757
+ # Determine node depth based on port label presence
758
+ is_client = node_type in ("client", "client_cluster")
759
+ if port_label:
760
+ # Nodes with port labels: no 3D base box, only the port label tile
761
+ node_depth = 0.0
762
+ else:
763
+ # Nodes without port labels: consistent 3D depth
764
+ node_depth = layout.tile_height * 0.15
765
+
766
+ group_attrs = _svg_node_group_attrs(None, name, node_type)
767
+ lines.append(f"<g{group_attrs}>")
768
+ lines.append(f"<title>{_escape_text(name)}</title>")
769
+ top, left, right = _iso_node_polygons(x, y, tile_w, tile_h, node_depth)
770
+ lines.append(
771
+ f'<polygon points="{_points_to_svg(top)}" fill="transparent" '
772
+ 'pointer-events="all" class="node-hitbox"/>'
773
+ )
774
+ left_fill = theme.node_side_left
775
+ right_fill = theme.node_side_right
776
+ _iso_render_faces(
777
+ lines,
778
+ top=top,
779
+ left=left,
780
+ right=right,
781
+ fill=fill,
782
+ stroke=stroke,
783
+ left_fill=left_fill,
784
+ right_fill=right_fill,
785
+ node_depth=node_depth,
786
+ )
787
+ icon_href = icons.get(node_type, icons.get("other"))
788
+ center_x = x + tile_w / 2
789
+ center_y = y + tile_h / 2
790
+ icon_center_x = center_x
791
+ icon_center_y = center_y
792
+ iso_icon_size = min(tile_w, tile_h) * 1.26
793
+ if port_label:
794
+ font_size = max(options.font_size - 2, 8)
795
+ prefix = port_prefix or "switch"
796
+ icon_center_x, icon_center_y = _render_iso_port_label(
797
+ lines,
798
+ port_label=port_label,
799
+ node_type=node_type,
800
+ prefix=prefix,
801
+ center_x=center_x,
802
+ center_y=center_y,
803
+ tile_w=tile_w,
804
+ tile_h=tile_h,
805
+ fill=fill,
806
+ stroke=stroke,
807
+ left_fill=left_fill,
808
+ right_fill=right_fill,
809
+ font_size=font_size,
810
+ )
811
+ if node_type == "ap":
812
+ icon_center_y -= tile_h * 0.4
813
+ if icon_href:
814
+ icon_x = icon_center_x - iso_icon_size / 2
815
+ icon_lift = tile_h * (0.02 if port_label else 0.04)
816
+ icon_y = icon_center_y - iso_icon_size / 2 - icon_lift - tile_h * 0.05
817
+ if is_client:
818
+ icon_y -= tile_h * 0.05
819
+ lines.append(
820
+ f'<image href="{icon_href}" x="{icon_x}" y="{icon_y}" '
821
+ f'width="{iso_icon_size}" height="{iso_icon_size}" '
822
+ f'preserveAspectRatio="xMidYMid meet" filter="url(#iso-icon-emboss)"/>'
823
+ )
824
+
825
+ # Position name label
826
+ name_font_size = max(options.font_size - 2, 8)
827
+ if not port_label and node_depth > 0:
828
+ # Nodes without port labels: render name on front (left) face
829
+ name_x, name_y, name_angle = _iso_front_face_label_position(
830
+ left,
831
+ tile_height=tile_h,
832
+ font_size=name_font_size,
833
+ )
834
+ else:
835
+ # Nodes with port labels: render name on bottom edge of top face
836
+ name_x, name_y, name_angle = _iso_name_label_position(
837
+ top,
838
+ tile_width=tile_w,
839
+ tile_height=tile_h,
840
+ font_size=name_font_size,
841
+ )
842
+
843
+ name_transform = (
844
+ f"translate({name_x} {name_y}) rotate({name_angle}) skewX(30) "
845
+ f"translate({-name_x} {-name_y})"
846
+ )
847
+ lines.append(
848
+ f'<text x="{name_x}" y="{name_y}" class="node-label" text-anchor="middle" fill="{theme.text_primary}" '
849
+ f'font-size="{name_font_size}" transform="{name_transform}">{_escape_text(name)}</text>'
850
+ )
851
+ lines.append("</g>")
852
+
853
+
854
+ def _render_iso_nodes(
855
+ lines: list[str],
856
+ *,
857
+ positions: dict[str, tuple[float, float]],
858
+ node_types: dict[str, str],
859
+ icons: dict[str, str],
860
+ options: SvgOptions,
861
+ layout: IsoLayout,
862
+ node_port_labels: dict[str, str],
863
+ node_port_prefix: dict[str, str],
864
+ theme: SvgTheme,
865
+ ) -> None:
866
+ for name, (x, y) in positions.items():
867
+ _render_iso_node(
868
+ lines,
869
+ name=name,
870
+ x=x,
871
+ y=y,
872
+ node_type=node_types.get(name, "other"),
873
+ icons=icons,
874
+ options=options,
875
+ port_label=node_port_labels.get(name),
876
+ port_prefix=node_port_prefix.get(name),
877
+ layout=layout,
878
+ theme=theme,
879
+ )
880
+
881
+
882
+ def _render_iso_wan_upstream(
883
+ lines: list[str],
884
+ wan_info: WanInfo,
885
+ gateway_position: tuple[float, float],
886
+ layout: IsoLayout,
887
+ options: SvgOptions,
888
+ theme: SvgTheme,
889
+ ) -> None:
890
+ """Render WAN upstream visualization (isometric view)."""
891
+ gx, gy = gateway_position
892
+ tile_w = layout.tile_width
893
+ tile_h = layout.tile_height
894
+
895
+ # Build label lines to calculate box size
896
+ label_lines = _build_wan_label_lines(wan_info)
897
+ font_size = max(options.font_size - 1, 8)
898
+
899
+ # Calculate box dimensions based on content
900
+ globe_size = 40
901
+ padding = 12
902
+ line_height = font_size + 4
903
+ max_text_width = max((len(line) for line in label_lines), default=10) * font_size * 0.55
904
+ box_width = max(globe_size + padding * 2, max_text_width + padding * 2)
905
+ box_height = globe_size + len(label_lines) * line_height + padding * 3
906
+
907
+ # Position box to the east (right side) of the gateway
908
+ # Move along the isometric NE direction (slope ~0.5 for parallel grid lines)
909
+ box_x = gx + tile_w + 60
910
+ # Position so link runs parallel to isometric grid (y offset follows NE slope)
911
+ box_y = gy - tile_h / 2 - box_height / 2 + 38
912
+
913
+ # Connection point on gateway (east edge of tile)
914
+ gateway_connect_x = gx + tile_w * 0.75
915
+ gateway_connect_y = gy + tile_h * 0.25
916
+
917
+ # Connection point on box (left edge, middle)
918
+ box_connect_x = box_x
919
+ box_connect_y = box_y + box_height / 2
920
+
921
+ lines.append('<g class="wan-upstream">')
922
+
923
+ # Draw connector line from gateway to box
924
+ lines.append(
925
+ f'<path d="M {gateway_connect_x} {gateway_connect_y} '
926
+ f'L {box_connect_x} {box_connect_y}" '
927
+ f'stroke="#0288d1" stroke-width="3" fill="none" '
928
+ f'stroke-linecap="round" opacity="0.8"/>'
929
+ )
930
+
931
+ # Draw bounding box with rounded corners
932
+ lines.append(
933
+ f'<rect x="{box_x}" y="{box_y}" width="{box_width}" height="{box_height}" '
934
+ f'rx="8" ry="8" fill="{theme.wan_background}" stroke="{theme.wan_globe[1]}" stroke-width="2"/>'
935
+ )
936
+
937
+ # Draw globe icon inline with gradient fill
938
+ globe_cx = box_x + box_width / 2
939
+ globe_cy = box_y + padding + globe_size / 2
940
+ globe_r = globe_size / 2 - 2
941
+ lines.append(f'<g transform="translate({globe_cx}, {globe_cy})">')
942
+ # Globe circle
943
+ lines.append(
944
+ f'<circle cx="0" cy="0" r="{globe_r}" fill="none" '
945
+ f'stroke="url(#iso-globe)" stroke-width="2"/>'
946
+ )
947
+ # Vertical ellipse (meridian)
948
+ lines.append(
949
+ f'<ellipse cx="0" cy="0" rx="{globe_r * 0.35}" ry="{globe_r}" '
950
+ f'fill="none" stroke="url(#iso-globe)" stroke-width="1.5"/>'
951
+ )
952
+ # Horizontal line (equator)
953
+ lines.append(
954
+ f'<line x1="{-globe_r}" y1="0" x2="{globe_r}" y2="0" '
955
+ f'stroke="url(#iso-globe)" stroke-width="1.5"/>'
956
+ )
957
+ # Latitude lines
958
+ lines.append(
959
+ f'<ellipse cx="0" cy="{-globe_r * 0.5}" rx="{globe_r * 0.87}" ry="{globe_r * 0.2}" '
960
+ f'fill="none" stroke="url(#iso-globe)" stroke-width="1"/>'
961
+ )
962
+ lines.append(
963
+ f'<ellipse cx="0" cy="{globe_r * 0.5}" rx="{globe_r * 0.87}" ry="{globe_r * 0.2}" '
964
+ f'fill="none" stroke="url(#iso-globe)" stroke-width="1"/>'
965
+ )
966
+ lines.append("</g>")
967
+
968
+ # Render label lines
969
+ text_x = box_x + box_width / 2
970
+ text_y = box_y + padding + globe_size + padding + font_size
971
+ for i, label_text in enumerate(label_lines):
972
+ y = text_y + i * line_height
973
+ lines.append(
974
+ f'<text x="{text_x}" y="{y}" text-anchor="middle" '
975
+ f'fill="{theme.text_primary}" font-size="{font_size}">'
976
+ f"{_escape_text(label_text)}</text>"
977
+ )
978
+
979
+ lines.append("</g>")
980
+
981
+
982
+ @dataclass(frozen=True)
983
+ class IsoGroupBounds:
984
+ name: str
985
+ points: list[tuple[float, float]] # 4 corners of the parallelogram
986
+ label_x: float
987
+ label_y: float
988
+
989
+
990
+ def _compute_iso_group_bounds(
991
+ grid_positions: dict[str, tuple[float, float]],
992
+ groups: dict[str, list[str]],
993
+ group_order: list[str] | None,
994
+ layout: IsoLayout,
995
+ offset_x: float,
996
+ offset_y: float,
997
+ options: SvgOptions,
998
+ ) -> list[IsoGroupBounds]:
999
+ """Compute isometric group bounds as parallelograms aligned with grid."""
1000
+ ordered_groups = _resolve_group_order(groups, group_order)
1001
+ node_to_group = _build_node_to_group_map(groups)
1002
+ bounds_list: list[IsoGroupBounds] = []
1003
+ # Convert screen padding to grid units
1004
+ padding = options.group_padding / layout.step_width + 0.5
1005
+
1006
+ for group_name in ordered_groups:
1007
+ group_grid = {
1008
+ n: pos for n, pos in grid_positions.items() if node_to_group.get(n) == group_name
1009
+ }
1010
+ if not group_grid:
1011
+ continue
1012
+ bounds = _iso_group_parallelogram(
1013
+ group_name, group_grid, layout, offset_x, offset_y, padding
1014
+ )
1015
+ bounds_list.append(bounds)
1016
+ return bounds_list
1017
+
1018
+
1019
+ def _iso_group_parallelogram(
1020
+ name: str,
1021
+ group_grid: dict[str, tuple[float, float]],
1022
+ layout: IsoLayout,
1023
+ offset_x: float,
1024
+ offset_y: float,
1025
+ padding: float,
1026
+ ) -> IsoGroupBounds:
1027
+ """Create isometric parallelogram bounds from grid positions."""
1028
+ gxs = [gx for gx, _ in group_grid.values()]
1029
+ gys = [gy for _, gy in group_grid.values()]
1030
+
1031
+ is_single_node = len(group_grid) == 1
1032
+ if is_single_node:
1033
+ # For single-node groups: boundary centered around the node
1034
+ # Shift to align with node's visual center
1035
+ node_half = 0.8 # Half-size of boundary in grid units
1036
+ center_gx = min(gxs) + 1.45 # Tuned for visual centering
1037
+ center_gy = min(gys) + 0.45
1038
+ min_gx = center_gx - node_half
1039
+ max_gx = center_gx + node_half
1040
+ min_gy = center_gy - node_half
1041
+ max_gy = center_gy + node_half
1042
+ else:
1043
+ # For multi-node groups: use grid spacing with padding
1044
+ min_gx = min(gxs) - padding
1045
+ max_gx = max(gxs) + layout.grid_spacing_x + padding
1046
+ min_gy = min(gys) - padding
1047
+ max_gy = max(gys) + layout.grid_spacing_y + padding
1048
+
1049
+ # Project grid corners to screen coordinates
1050
+ corners_grid = [
1051
+ (min_gx, min_gy), # top
1052
+ (max_gx, min_gy), # right
1053
+ (max_gx, max_gy), # bottom
1054
+ (min_gx, max_gy), # left
1055
+ ]
1056
+ points = [
1057
+ (
1058
+ _iso_project(layout, gx, gy)[0] + offset_x,
1059
+ _iso_project(layout, gx, gy)[1] + offset_y,
1060
+ )
1061
+ for gx, gy in corners_grid
1062
+ ]
1063
+
1064
+ # Position label at the top corner, offset inward along the right edge
1065
+ top_x, top_y = points[0]
1066
+ right_x, right_y = points[1]
1067
+ label_x = top_x + (right_x - top_x) * 0.15 - 30 # Shifted NW
1068
+ label_y = top_y + (right_y - top_y) * 0.15 - 20
1069
+ return IsoGroupBounds(name=name, points=points, label_x=label_x, label_y=label_y)
1070
+
1071
+
1072
+ def _render_iso_group_boundaries(
1073
+ lines: list[str],
1074
+ bounds_list: list[IsoGroupBounds],
1075
+ theme: SvgTheme,
1076
+ ) -> None:
1077
+ """Render isometric group boundaries as parallelograms."""
1078
+ label_size = _ISO_GROUP_LABEL_SIZE
1079
+ iso_angle = _ISO_PERSPECTIVE_ANGLE
1080
+ for bounds in bounds_list:
1081
+ group_attr = _escape_html(bounds.name, quote=True)
1082
+ fill, stroke = theme.group_colors(bounds.name)
1083
+ points_str = " ".join(f"{x},{y}" for x, y in bounds.points)
1084
+ lines.append(f'<g class="network-group" data-group-name="{group_attr}">')
1085
+ lines.append(
1086
+ f'<polygon class="group-boundary" points="{points_str}" '
1087
+ f'fill="{fill}" fill-opacity="0.35" '
1088
+ f'stroke="{stroke}" stroke-width="{theme.group_stroke_width}"/>'
1089
+ )
1090
+ label_text = _escape_text(bounds.name.capitalize())
1091
+ lx, ly = bounds.label_x, bounds.label_y
1092
+ # Isometric transform: rotate + skew to follow 30° perspective
1093
+ label_transform = (
1094
+ f"translate({lx} {ly}) rotate({iso_angle}) skewX({iso_angle}) translate({-lx} {-ly})"
1095
+ )
1096
+ # Text with thin outline for readability
1097
+ lines.append(
1098
+ f'<text class="group-label" x="{lx}" y="{ly}" '
1099
+ f'font-size="{label_size}" font-weight="bold" fill="{stroke}" '
1100
+ f'stroke="#ffffff" stroke-width="2" paint-order="stroke fill" '
1101
+ f'opacity="0.7" transform="{label_transform}">'
1102
+ f"{label_text}</text>"
1103
+ )
1104
+ lines.append("</g>")
1105
+
1106
+
1107
+ def render_svg_isometric(
1108
+ edges: list[Edge],
1109
+ *,
1110
+ node_types: dict[str, str],
1111
+ options: SvgOptions | None = None,
1112
+ theme: SvgTheme = DEFAULT_THEME,
1113
+ groups: dict[str, list[str]] | None = None,
1114
+ group_order: list[str] | None = None,
1115
+ wan_info: WanInfo | None = None,
1116
+ ) -> str:
1117
+ options = options or SvgOptions()
1118
+ per_type_decals = _build_decal_colors(theme)
1119
+ icons = _load_isometric_icons(theme.icon_set, theme.icon_decal, per_type_decals)
1120
+ layout_positions = _iso_layout_positions(edges, node_types, options)
1121
+ layout = layout_positions.layout
1122
+ grid_positions = layout_positions.grid_positions
1123
+ positions = layout_positions.positions
1124
+
1125
+ out_width = options.width or int(layout_positions.width)
1126
+ out_height = options.height or int(layout_positions.height)
1127
+
1128
+ lines = [
1129
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
1130
+ f'viewBox="0 0 {layout_positions.width} {layout_positions.height}">',
1131
+ svg_defs("iso", theme),
1132
+ _svg_style_block(theme, options.font_size, iso=True),
1133
+ f'<rect width="100%" height="100%" fill="{theme.background}"/>',
1134
+ ]
1135
+
1136
+ use_grouped = options.layout_mode == "grouped" and groups
1137
+ if use_grouped and groups:
1138
+ group_bounds_list = _compute_iso_group_bounds(
1139
+ grid_positions,
1140
+ groups,
1141
+ group_order,
1142
+ layout,
1143
+ layout_positions.offset_x,
1144
+ layout_positions.offset_y,
1145
+ options,
1146
+ )
1147
+ _render_iso_group_boundaries(lines, group_bounds_list, theme)
1148
+
1149
+ grid_lines = _iso_grid_lines(grid_positions, layout, theme.grid_color)
1150
+ if grid_lines:
1151
+ lines.append('<g class="iso-grid" opacity="0.7">')
1152
+ lines.extend(grid_lines)
1153
+ lines.append("</g>")
1154
+
1155
+ node_port_labels: dict[str, str] = {}
1156
+ node_port_prefix: dict[str, str] = {}
1157
+ _render_iso_edges(
1158
+ lines,
1159
+ edges,
1160
+ positions=positions,
1161
+ grid_positions=grid_positions,
1162
+ node_types=node_types,
1163
+ layout=layout,
1164
+ theme=theme,
1165
+ offset_x=layout_positions.offset_x,
1166
+ offset_y=layout_positions.offset_y,
1167
+ node_port_labels=node_port_labels,
1168
+ node_port_prefix=node_port_prefix,
1169
+ )
1170
+ _render_iso_nodes(
1171
+ lines,
1172
+ positions=positions,
1173
+ node_types=node_types,
1174
+ icons=icons,
1175
+ options=options,
1176
+ layout=layout,
1177
+ node_port_labels=node_port_labels,
1178
+ node_port_prefix=node_port_prefix,
1179
+ theme=theme,
1180
+ )
1181
+
1182
+ # Render WAN upstream visualization
1183
+ if wan_info:
1184
+ # Find gateway position
1185
+ gateway_name = None
1186
+ for name, ntype in node_types.items():
1187
+ if ntype == "gateway":
1188
+ gateway_name = name
1189
+ break
1190
+ if gateway_name and gateway_name in positions:
1191
+ _render_iso_wan_upstream(
1192
+ lines, wan_info, positions[gateway_name], layout, options, theme
1193
+ )
1194
+
1195
+ lines.append("</svg>")
1196
+ return "\n".join(lines) + "\n"