unifi-network-maps 1.2.1__py3-none-any.whl → 1.3.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 (82) hide show
  1. unifi_network_maps/__init__.py +1 -0
  2. unifi_network_maps/adapters/__init__.py +1 -0
  3. {unifi_mermaid → unifi_network_maps/adapters}/config.py +7 -1
  4. {unifi_mermaid → unifi_network_maps/adapters}/unifi.py +1 -1
  5. unifi_network_maps/assets/themes/dark.yaml +47 -0
  6. unifi_network_maps/assets/themes/default.yaml +47 -0
  7. unifi_network_maps/cli/__init__.py +41 -0
  8. unifi_network_maps/cli/__main__.py +8 -0
  9. unifi_network_maps/cli/main.py +281 -0
  10. unifi_network_maps/io/__init__.py +1 -0
  11. {unifi_mermaid → unifi_network_maps/io}/debug.py +1 -1
  12. unifi_network_maps/model/__init__.py +1 -0
  13. unifi_network_maps/model/labels.py +35 -0
  14. {unifi_mermaid → unifi_network_maps/model}/lldp.py +19 -33
  15. unifi_network_maps/model/ports.py +23 -0
  16. {unifi_mermaid → unifi_network_maps/model}/topology.py +216 -89
  17. unifi_network_maps/render/__init__.py +1 -0
  18. {unifi_mermaid → unifi_network_maps/render}/mermaid.py +21 -16
  19. unifi_network_maps/render/mermaid_theme.py +46 -0
  20. {unifi_mermaid → unifi_network_maps/render}/svg.py +208 -175
  21. unifi_network_maps/render/svg_theme.py +64 -0
  22. unifi_network_maps/render/theme.py +90 -0
  23. {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/METADATA +63 -8
  24. unifi_network_maps-1.3.0.dist-info/RECORD +75 -0
  25. unifi_network_maps-1.3.0.dist-info/entry_points.txt +2 -0
  26. unifi_network_maps-1.3.0.dist-info/licenses/LICENSES.md +10 -0
  27. unifi_network_maps-1.3.0.dist-info/top_level.txt +1 -0
  28. unifi_mermaid/__init__.py +0 -1
  29. unifi_mermaid/cli.py +0 -197
  30. unifi_mermaid/labels.py +0 -15
  31. unifi_network_maps-1.2.1.dist-info/RECORD +0 -63
  32. unifi_network_maps-1.2.1.dist-info/entry_points.txt +0 -2
  33. unifi_network_maps-1.2.1.dist-info/licenses/LICENSES.md +0 -10
  34. unifi_network_maps-1.2.1.dist-info/top_level.txt +0 -1
  35. {unifi_mermaid → unifi_network_maps}/assets/__init__.py +0 -0
  36. {unifi_mermaid → unifi_network_maps}/assets/icons/__init__.py +0 -0
  37. {unifi_mermaid → unifi_network_maps}/assets/icons/access-point.svg +0 -0
  38. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/ISOPACKS_LICENSE +0 -0
  39. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/block.svg +0 -0
  40. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cache.svg +0 -0
  41. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cardterminal.svg +0 -0
  42. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cloud.svg +0 -0
  43. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cronjob.svg +0 -0
  44. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/cube.svg +0 -0
  45. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/desktop.svg +0 -0
  46. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/diamond.svg +0 -0
  47. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/dns.svg +0 -0
  48. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/document.svg +0 -0
  49. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/firewall.svg +0 -0
  50. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/function-module.svg +0 -0
  51. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/image.svg +0 -0
  52. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/laptop.svg +0 -0
  53. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/loadbalancer.svg +0 -0
  54. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/lock.svg +0 -0
  55. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mail.svg +0 -0
  56. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mailmultiple.svg +0 -0
  57. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/mobiledevice.svg +0 -0
  58. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/office.svg +0 -0
  59. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/package-module.svg +0 -0
  60. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/paymentcard.svg +0 -0
  61. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/plane.svg +0 -0
  62. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/printer.svg +0 -0
  63. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/pyramid.svg +0 -0
  64. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/queue.svg +0 -0
  65. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/router.svg +0 -0
  66. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/server.svg +0 -0
  67. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/speech.svg +0 -0
  68. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/sphere.svg +0 -0
  69. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/storage.svg +0 -0
  70. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/switch-module.svg +0 -0
  71. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/tower.svg +0 -0
  72. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck-2.svg +0 -0
  73. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/truck.svg +0 -0
  74. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/user.svg +0 -0
  75. {unifi_mermaid → unifi_network_maps}/assets/icons/isometric/vm.svg +0 -0
  76. {unifi_mermaid → unifi_network_maps}/assets/icons/laptop.svg +0 -0
  77. {unifi_mermaid → unifi_network_maps}/assets/icons/router-network.svg +0 -0
  78. {unifi_mermaid → unifi_network_maps}/assets/icons/server-network.svg +0 -0
  79. {unifi_mermaid → unifi_network_maps}/assets/icons/server.svg +0 -0
  80. {unifi_mermaid → unifi_network_maps/io}/export.py +0 -0
  81. {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/WHEEL +0 -0
  82. {unifi_network_maps-1.2.1.dist-info → unifi_network_maps-1.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -7,7 +7,8 @@ import math
7
7
  from dataclasses import dataclass
8
8
  from pathlib import Path
9
9
 
10
- from .topology import Edge
10
+ from ..model.topology import Edge
11
+ from .svg_theme import DEFAULT_THEME, SvgTheme, svg_defs
11
12
 
12
13
 
13
14
  @dataclass(frozen=True)
@@ -23,6 +24,45 @@ class SvgOptions:
23
24
  height: int | None = None
24
25
 
25
26
 
27
+ @dataclass(frozen=True)
28
+ class IsoLayout:
29
+ iso_angle: float
30
+ tile_width: float
31
+ tile_height: float
32
+ step_width: float
33
+ step_height: float
34
+ grid_spacing_x: int
35
+ grid_spacing_y: int
36
+ padding: float
37
+ tile_y_offset: float
38
+ extra_pad: float
39
+
40
+
41
+ def _iso_layout(options: SvgOptions) -> IsoLayout:
42
+ tile_width = options.node_width * 1.5
43
+ iso_angle = math.radians(30.0)
44
+ tile_height = tile_width * math.tan(iso_angle)
45
+ step_width = tile_width
46
+ step_height = tile_height
47
+ grid_spacing_x = max(2, 1 + int(round(options.h_gap / max(tile_width, 1))))
48
+ grid_spacing_y = max(2, 1 + int(round(options.v_gap / max(tile_height, 1))))
49
+ padding = float(options.padding)
50
+ tile_y_offset = tile_height / 2
51
+ extra_pad = max(12.0, tile_width * 0.35)
52
+ return IsoLayout(
53
+ iso_angle=iso_angle,
54
+ tile_width=tile_width,
55
+ tile_height=tile_height,
56
+ step_width=step_width,
57
+ step_height=step_height,
58
+ grid_spacing_x=grid_spacing_x,
59
+ grid_spacing_y=grid_spacing_y,
60
+ padding=padding,
61
+ tile_y_offset=tile_y_offset,
62
+ extra_pad=extra_pad,
63
+ )
64
+
65
+
26
66
  _TYPE_ORDER = ["gateway", "switch", "ap", "client", "other"]
27
67
  _ICON_FILES = {
28
68
  "gateway": "router-network.svg",
@@ -93,6 +133,136 @@ def _compact_edge_label(
93
133
  return label
94
134
 
95
135
 
136
+ def _iso_tile_points(
137
+ center_x: float, center_y: float, width: float, height: float
138
+ ) -> list[tuple[float, float]]:
139
+ return [
140
+ (center_x, center_y - height / 2),
141
+ (center_x + width / 2, center_y),
142
+ (center_x, center_y + height / 2),
143
+ (center_x - width / 2, center_y),
144
+ ]
145
+
146
+
147
+ def _points_to_svg(points: list[tuple[float, float]]) -> str:
148
+ return " ".join(f"{px},{py}" for px, py in points)
149
+
150
+
151
+ def _format_port_label_lines(
152
+ port_label: str,
153
+ *,
154
+ node_type: str,
155
+ prefix: str,
156
+ max_chars: int,
157
+ ) -> list[str]:
158
+ def _port_only(segment: str) -> str:
159
+ port = _extract_port_text(segment)
160
+ if port:
161
+ return port
162
+ lower = segment.lower()
163
+ idx = lower.rfind("port ")
164
+ if idx != -1:
165
+ return segment[idx:].strip()
166
+ return segment.split(":", 1)[-1].strip()
167
+
168
+ def _truncate(text: str, max_len: int = max_chars) -> str:
169
+ return text[: max_len - 3].rstrip() + "..." if len(text) > max_len else text
170
+
171
+ if "<->" in port_label:
172
+ left_part, right_part = (part.strip() for part in port_label.split("<->", 1))
173
+ front_text = _truncate(f"{prefix}: {_port_only(left_part)}")
174
+ side_prefix = prefix if node_type == "client" else "local"
175
+ side_text = _truncate(f"{side_prefix}: {_port_only(right_part)}")
176
+ return [line for line in (front_text, side_text) if line]
177
+ side_prefix = prefix if node_type == "client" else "local"
178
+ side_text = _truncate(f"{side_prefix}: {_port_only(port_label)}")
179
+ return [side_text]
180
+
181
+
182
+ def _iso_front_text_position(
183
+ top_points: list[tuple[float, float]], tile_width: float, tile_height: float
184
+ ) -> tuple[float, float, float]:
185
+ left_edge_top = top_points[0]
186
+ left_edge_bottom = top_points[3]
187
+ edge_mid_x = (left_edge_top[0] + left_edge_bottom[0]) / 2
188
+ edge_mid_y = (left_edge_top[1] + left_edge_bottom[1]) / 2
189
+ center_x = sum(px for px, _py in top_points) / len(top_points)
190
+ center_y = sum(py for _px, py in top_points) / len(top_points)
191
+ normal_x = center_x - edge_mid_x
192
+ normal_y = center_y - edge_mid_y
193
+ normal_len = math.hypot(normal_x, normal_y) or 1.0
194
+ normal_x /= normal_len
195
+ normal_y /= normal_len
196
+ inset = tile_height * 0.27
197
+ text_x = edge_mid_x + normal_x * inset - tile_width * 0.16
198
+ text_y = edge_mid_y + normal_y * inset + tile_height * 0.02
199
+ name_edge_left = top_points[3]
200
+ name_edge_right = top_points[2]
201
+ angle = math.degrees(
202
+ math.atan2(
203
+ name_edge_right[1] - name_edge_left[1],
204
+ name_edge_right[0] - name_edge_left[0],
205
+ )
206
+ )
207
+ return text_x, text_y, angle
208
+
209
+
210
+ def _render_iso_text(
211
+ lines: list[str],
212
+ *,
213
+ text_x: float,
214
+ text_y: float,
215
+ angle: float,
216
+ text_lines: list[str],
217
+ font_size: int,
218
+ fill: str,
219
+ ) -> None:
220
+ line_height = font_size + 2
221
+ start_y = text_y - (len(text_lines) - 1) * line_height / 2
222
+ text_transform = (
223
+ f"translate({text_x} {start_y}) rotate({angle}) skewX(30) translate({-text_x} {-start_y})"
224
+ )
225
+ lines.append(
226
+ f'<text x="{text_x}" y="{start_y}" text-anchor="middle" fill="{fill}" '
227
+ f'font-size="{font_size}" font-style="normal" '
228
+ f'transform="{text_transform}">'
229
+ )
230
+ for idx, line in enumerate(text_lines):
231
+ dy = 0 if idx == 0 else line_height
232
+ lines.append(f'<tspan x="{text_x}" dy="{dy}">{_escape_text(line)}</tspan>')
233
+ lines.append("</text>")
234
+
235
+
236
+ def _iso_name_label_position(
237
+ top_points: list[tuple[float, float]],
238
+ *,
239
+ tile_width: float,
240
+ tile_height: float,
241
+ font_size: int,
242
+ ) -> tuple[float, float, float]:
243
+ name_edge_left = top_points[3]
244
+ name_edge_right = top_points[2]
245
+ name_mid_x = (name_edge_left[0] + name_edge_right[0]) / 2
246
+ name_mid_y = (name_edge_left[1] + name_edge_right[1]) / 2
247
+ name_center_x = sum(px for px, _py in top_points) / len(top_points)
248
+ name_center_y = sum(py for _px, py in top_points) / len(top_points)
249
+ name_normal_x = name_center_x - name_mid_x
250
+ name_normal_y = name_center_y - name_mid_y
251
+ name_normal_len = math.hypot(name_normal_x, name_normal_y) or 1.0
252
+ name_normal_x /= name_normal_len
253
+ name_normal_y /= name_normal_len
254
+ name_inset = tile_height * 0.13
255
+ name_x = name_mid_x + name_normal_x * name_inset - tile_width * 0.08
256
+ name_y = name_mid_y + name_normal_y * name_inset + font_size - tile_height * 0.05
257
+ name_angle = math.degrees(
258
+ math.atan2(
259
+ name_edge_right[1] - name_edge_left[1],
260
+ name_edge_right[0] - name_edge_left[0],
261
+ )
262
+ )
263
+ return name_x, name_y, name_angle
264
+
265
+
96
266
  def _wrap_text(label: str, *, max_len: int = 24) -> list[str]:
97
267
  if len(label) <= max_len:
98
268
  return [label]
@@ -250,6 +420,7 @@ def render_svg(
250
420
  *,
251
421
  node_types: dict[str, str],
252
422
  options: SvgOptions | None = None,
423
+ theme: SvgTheme = DEFAULT_THEME,
253
424
  ) -> str:
254
425
  options = options or SvgOptions()
255
426
  icons = _load_icons()
@@ -260,42 +431,12 @@ def render_svg(
260
431
  lines = [
261
432
  f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
262
433
  f'viewBox="0 0 {width} {height}">',
263
- "<defs>"
264
- '<linearGradient id="link-standard" x1="0%" y1="0%" x2="100%" y2="0%">'
265
- '<stop offset="0%" stop-color="#16a085"/>'
266
- '<stop offset="100%" stop-color="#2ecc71"/>'
267
- "</linearGradient>"
268
- '<linearGradient id="link-poe" x1="0%" y1="0%" x2="100%" y2="0%">'
269
- '<stop offset="0%" stop-color="#1e88e5"/>'
270
- '<stop offset="100%" stop-color="#42a5f5"/>'
271
- "</linearGradient>"
272
- '<linearGradient id="node-gateway" x1="0%" y1="0%" x2="100%" y2="100%">'
273
- '<stop offset="0%" stop-color="#ffd199"/>'
274
- '<stop offset="100%" stop-color="#ffb15a"/>'
275
- "</linearGradient>"
276
- '<linearGradient id="node-switch" x1="0%" y1="0%" x2="100%" y2="100%">'
277
- '<stop offset="0%" stop-color="#bfe4ff"/>'
278
- '<stop offset="100%" stop-color="#8ac6ff"/>'
279
- "</linearGradient>"
280
- '<linearGradient id="node-ap" x1="0%" y1="0%" x2="100%" y2="100%">'
281
- '<stop offset="0%" stop-color="#c4f2d4"/>'
282
- '<stop offset="100%" stop-color="#8ee3b4"/>'
283
- "</linearGradient>"
284
- '<linearGradient id="node-client" x1="0%" y1="0%" x2="100%" y2="100%">'
285
- '<stop offset="0%" stop-color="#e4ccff"/>'
286
- '<stop offset="100%" stop-color="#c5a4ff"/>'
287
- "</linearGradient>"
288
- '<linearGradient id="node-other" x1="0%" y1="0%" x2="100%" y2="100%">'
289
- '<stop offset="0%" stop-color="#e3e3e3"/>'
290
- '<stop offset="100%" stop-color="#cfcfcf"/>'
291
- "</linearGradient>"
292
- "</defs>",
434
+ svg_defs("", theme),
293
435
  f"<style>text{{font-family:Arial,Helvetica,sans-serif;font-size:{options.font_size}px;}}</style>",
294
436
  ]
295
437
 
296
438
  node_port_labels: dict[str, str] = {}
297
439
  node_port_prefix: dict[str, str] = {}
298
- node_port_prefix: dict[str, str] = {}
299
440
  for edge in edges:
300
441
  if edge.left not in positions or edge.right not in positions:
301
442
  continue
@@ -396,19 +537,20 @@ def render_svg_isometric(
396
537
  *,
397
538
  node_types: dict[str, str],
398
539
  options: SvgOptions | None = None,
540
+ theme: SvgTheme = DEFAULT_THEME,
399
541
  ) -> str:
400
542
  options = options or SvgOptions()
401
543
  icons = _load_isometric_icons()
402
544
  positions_index, levels = _tree_layout_indices(edges, node_types)
403
545
  if not positions_index:
404
546
  positions_index = {}
405
- tile_w = options.node_width * 1.5
406
- iso_angle = math.radians(30.0)
407
- tile_h = tile_w * math.tan(iso_angle)
408
- step_w = tile_w
409
- step_h = tile_h
410
- grid_spacing_x = max(2, 1 + int(round(options.h_gap / max(tile_w, 1))))
411
- grid_spacing_y = max(2, 1 + int(round(options.v_gap / max(tile_h, 1))))
547
+ layout = _iso_layout(options)
548
+ tile_w = layout.tile_width
549
+ tile_h = layout.tile_height
550
+ step_w = layout.step_width
551
+ step_h = layout.step_height
552
+ grid_spacing_x = layout.grid_spacing_x
553
+ grid_spacing_y = layout.grid_spacing_y
412
554
 
413
555
  grid_positions: dict[str, tuple[float, float]] = {}
414
556
  positions: dict[str, tuple[float, float]] = {}
@@ -438,8 +580,8 @@ def render_svg_isometric(
438
580
  min_x = min_y = 0.0
439
581
  max_x = max_y = 0.0
440
582
 
441
- padding = options.padding
442
- tile_y_offset = tile_h / 2
583
+ padding = layout.padding
584
+ tile_y_offset = layout.tile_y_offset
443
585
  offset_x = -min_x + padding
444
586
  offset_y = -min_y + padding + tile_y_offset
445
587
  for name, (x, y) in positions.items():
@@ -457,8 +599,8 @@ def render_svg_isometric(
457
599
  cx, cy = grid_center(gx, gy)
458
600
  return cx, cy
459
601
 
460
- width = max_x - min_x + tile_w + padding * 2
461
- height = max_y - min_y + tile_h + padding * 2 + tile_y_offset
602
+ width = max_x - min_x + tile_w + padding * 2 + layout.extra_pad
603
+ height = max_y - min_y + tile_h + padding * 2 + tile_y_offset + layout.extra_pad
462
604
 
463
605
  out_width = options.width or int(width)
464
606
  out_height = options.height or int(height)
@@ -466,36 +608,7 @@ def render_svg_isometric(
466
608
  lines = [
467
609
  f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
468
610
  f'viewBox="0 0 {width} {height}">',
469
- "<defs>"
470
- '<linearGradient id="iso-link-standard" x1="0%" y1="0%" x2="100%" y2="0%">'
471
- '<stop offset="0%" stop-color="#16a085"/>'
472
- '<stop offset="100%" stop-color="#2ecc71"/>'
473
- "</linearGradient>"
474
- '<linearGradient id="iso-link-poe" x1="0%" y1="0%" x2="100%" y2="0%">'
475
- '<stop offset="0%" stop-color="#1e88e5"/>'
476
- '<stop offset="100%" stop-color="#42a5f5"/>'
477
- "</linearGradient>"
478
- '<linearGradient id="iso-node-gateway" x1="0%" y1="0%" x2="100%" y2="100%">'
479
- '<stop offset="0%" stop-color="#ffd199"/>'
480
- '<stop offset="100%" stop-color="#ffb15a"/>'
481
- "</linearGradient>"
482
- '<linearGradient id="iso-node-switch" x1="0%" y1="0%" x2="100%" y2="100%">'
483
- '<stop offset="0%" stop-color="#bfe4ff"/>'
484
- '<stop offset="100%" stop-color="#8ac6ff"/>'
485
- "</linearGradient>"
486
- '<linearGradient id="iso-node-ap" x1="0%" y1="0%" x2="100%" y2="100%">'
487
- '<stop offset="0%" stop-color="#c4f2d4"/>'
488
- '<stop offset="100%" stop-color="#8ee3b4"/>'
489
- "</linearGradient>"
490
- '<linearGradient id="iso-node-client" x1="0%" y1="0%" x2="100%" y2="100%">'
491
- '<stop offset="0%" stop-color="#e4ccff"/>'
492
- '<stop offset="100%" stop-color="#c5a4ff"/>'
493
- "</linearGradient>"
494
- '<linearGradient id="iso-node-other" x1="0%" y1="0%" x2="100%" y2="100%">'
495
- '<stop offset="0%" stop-color="#e3e3e3"/>'
496
- '<stop offset="100%" stop-color="#cfcfcf"/>'
497
- "</linearGradient>"
498
- "</defs>",
611
+ svg_defs("iso", theme),
499
612
  f"<style>text{{font-family:Arial,Helvetica,sans-serif;font-size:{options.font_size}px;}}</style>",
500
613
  ]
501
614
 
@@ -537,15 +650,6 @@ def render_svg_isometric(
537
650
  node_port_labels: dict[str, str] = {}
538
651
  node_port_prefix: dict[str, str] = {}
539
652
 
540
- def iso_tile_points(cx: float, cy: float, width: float, height: float) -> str:
541
- points = [
542
- (cx, cy - height / 2),
543
- (cx + width / 2, cy),
544
- (cx, cy + height / 2),
545
- (cx - width / 2, cy),
546
- ]
547
- return " ".join(f"{px},{py}" for px, py in points)
548
-
549
653
  for edge in edges:
550
654
  if edge.left not in positions or edge.right not in positions:
551
655
  continue
@@ -667,14 +771,9 @@ def render_svg_isometric(
667
771
  label_center_x = center_x
668
772
  stack_depth = tile_h / 2
669
773
  label_center_y = y + tile_height / 2 - stack_depth
670
- tile_points = iso_tile_points(label_center_x, label_center_y, tile_width, tile_height)
774
+ top_points = _iso_tile_points(label_center_x, label_center_y, tile_width, tile_height)
775
+ tile_points = _points_to_svg(top_points)
671
776
  # Stack a shallow side to suggest elevation.
672
- top_points = [
673
- (label_center_x, label_center_y - tile_height / 2),
674
- (label_center_x + tile_width / 2, label_center_y),
675
- (label_center_x, label_center_y + tile_height / 2),
676
- (label_center_x - tile_width / 2, label_center_y),
677
- ]
678
777
  bottom_points = [(px, py + stack_depth) for px, py in top_points]
679
778
  # Right face uses points 1->2 and their offset counterparts.
680
779
  right_face = [
@@ -707,18 +806,6 @@ def render_svg_isometric(
707
806
  icon_center_x = label_center_x
708
807
  icon_center_y = label_center_y
709
808
  if port_label:
710
-
711
- def _port_only(segment: str) -> str:
712
- port = _extract_port_text(segment)
713
- if port:
714
- return port
715
- lower = segment.lower()
716
- idx = lower.rfind("port ")
717
- if idx != -1:
718
- return segment[idx:].strip()
719
- return segment.split(":", 1)[-1].strip()
720
-
721
- # Place port text along the front edge of the top tile.
722
809
  left_edge_top = top[0]
723
810
  left_edge_bottom = top[3]
724
811
  edge_len = math.hypot(
@@ -726,66 +813,26 @@ def render_svg_isometric(
726
813
  left_edge_bottom[1] - left_edge_top[1],
727
814
  )
728
815
  max_chars = max(6, int((edge_len * 0.85) / (font_size * 0.6)))
729
-
730
- def _truncate(text: str, max_len: int = max_chars) -> str:
731
- return text[: max_len - 3].rstrip() + "..." if len(text) > max_len else text
732
-
733
816
  prefix = node_port_prefix.get(name, "switch")
734
- if "<->" in port_label:
735
- left_part, right_part = (part.strip() for part in port_label.split("<->", 1))
736
- front_text = _truncate(f"{prefix}: {_port_only(left_part)}")
737
- side_prefix = prefix if node_type == "client" else "local"
738
- side_text = _truncate(f"{side_prefix}: {_port_only(right_part)}")
739
- else:
740
- front_text = ""
741
- side_prefix = prefix if node_type == "client" else "local"
742
- side_text = _truncate(f"{side_prefix}: {_port_only(port_label)}")
743
-
744
- edge_mid_x = (left_edge_top[0] + left_edge_bottom[0]) / 2
745
- edge_mid_y = (left_edge_top[1] + left_edge_bottom[1]) / 2
746
- center_x = sum(px for px, _py in top) / len(top)
747
- center_y = sum(py for _px, py in top) / len(top)
748
- normal_x = center_x - edge_mid_x
749
- normal_y = center_y - edge_mid_y
750
- normal_len = math.hypot(normal_x, normal_y) or 1.0
751
- normal_x /= normal_len
752
- normal_y /= normal_len
753
- inset = tile_h * 0.27
754
- text_x = edge_mid_x + normal_x * inset - tile_w * 0.16
755
- text_y = edge_mid_y + normal_y * inset + tile_h * 0.02
756
- edge_angle = math.degrees(
757
- math.atan2(
758
- left_edge_bottom[1] - left_edge_top[1],
759
- left_edge_bottom[0] - left_edge_top[0],
760
- )
817
+ front_lines = _format_port_label_lines(
818
+ port_label,
819
+ node_type=node_type,
820
+ prefix=prefix,
821
+ max_chars=max_chars,
761
822
  )
762
- name_edge_left = top[3]
763
- name_edge_right = top[2]
764
- name_angle = math.degrees(
765
- math.atan2(
766
- name_edge_right[1] - name_edge_left[1],
767
- name_edge_right[0] - name_edge_left[0],
768
- )
769
- )
770
- edge_angle = name_angle
771
-
772
- front_lines = [line for line in (front_text, side_text) if line]
773
823
  if front_lines:
774
- line_height = font_size + 2
775
- start_y = text_y - (len(front_lines) - 1) * line_height / 2
776
- text_transform = (
777
- f"translate({text_x} {start_y}) rotate({edge_angle}) skewX(30) "
778
- f"translate({-text_x} {-start_y})"
824
+ text_x, text_y, edge_angle = _iso_front_text_position(
825
+ top_points, tile_w, tile_h
779
826
  )
780
- lines.append(
781
- f'<text x="{text_x}" y="{start_y}" text-anchor="middle" fill="#555" '
782
- f'font-size="{font_size}" font-style="normal" '
783
- f'transform="{text_transform}">'
827
+ _render_iso_text(
828
+ lines,
829
+ text_x=text_x,
830
+ text_y=text_y,
831
+ angle=edge_angle,
832
+ text_lines=front_lines,
833
+ font_size=font_size,
834
+ fill="#555",
784
835
  )
785
- for idx, line in enumerate(front_lines):
786
- dy = 0 if idx == 0 else line_height
787
- lines.append(f'<tspan x="{text_x}" dy="{dy}">{_escape_text(line)}</tspan>')
788
- lines.append("</text>")
789
836
 
790
837
  if node_type == "ap":
791
838
  icon_center_y -= tile_h * 0.4
@@ -802,25 +849,11 @@ def render_svg_isometric(
802
849
  )
803
850
 
804
851
  name_font_size = max(options.font_size - 2, 8)
805
- name_edge_left = top[3]
806
- name_edge_right = top[2]
807
- name_mid_x = (name_edge_left[0] + name_edge_right[0]) / 2
808
- name_mid_y = (name_edge_left[1] + name_edge_right[1]) / 2
809
- name_center_x = sum(px for px, _py in top) / len(top)
810
- name_center_y = sum(py for _px, py in top) / len(top)
811
- name_normal_x = name_center_x - name_mid_x
812
- name_normal_y = name_center_y - name_mid_y
813
- name_normal_len = math.hypot(name_normal_x, name_normal_y) or 1.0
814
- name_normal_x /= name_normal_len
815
- name_normal_y /= name_normal_len
816
- name_inset = tile_h * 0.13
817
- name_x = name_mid_x + name_normal_x * name_inset - tile_w * 0.08
818
- name_y = name_mid_y + name_normal_y * name_inset + name_font_size - tile_h * 0.05
819
- name_angle = math.degrees(
820
- math.atan2(
821
- name_edge_right[1] - name_edge_left[1],
822
- name_edge_right[0] - name_edge_left[0],
823
- )
852
+ name_x, name_y, name_angle = _iso_name_label_position(
853
+ top,
854
+ tile_width=tile_w,
855
+ tile_height=tile_h,
856
+ font_size=name_font_size,
824
857
  )
825
858
  name_transform = (
826
859
  f"translate({name_x} {name_y}) rotate({name_angle}) skewX(30) "
@@ -0,0 +1,64 @@
1
+ """Shared SVG defs and theming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class SvgTheme:
10
+ link_standard: tuple[str, str]
11
+ link_poe: tuple[str, str]
12
+ node_gateway: tuple[str, str]
13
+ node_switch: tuple[str, str]
14
+ node_ap: tuple[str, str]
15
+ node_client: tuple[str, str]
16
+ node_other: tuple[str, str]
17
+
18
+
19
+ DEFAULT_THEME = SvgTheme(
20
+ link_standard=("#16a085", "#2ecc71"),
21
+ link_poe=("#1e88e5", "#42a5f5"),
22
+ node_gateway=("#ffd199", "#ffb15a"),
23
+ node_switch=("#bfe4ff", "#8ac6ff"),
24
+ node_ap=("#c4f2d4", "#8ee3b4"),
25
+ node_client=("#e4ccff", "#c5a4ff"),
26
+ node_other=("#e3e3e3", "#cfcfcf"),
27
+ )
28
+
29
+
30
+ def svg_defs(prefix: str, theme: SvgTheme = DEFAULT_THEME) -> str:
31
+ gradient_prefix = f"{prefix}-" if prefix else ""
32
+ node_prefix = f"{prefix}-node-" if prefix else "node-"
33
+ return (
34
+ "<defs>"
35
+ f'<linearGradient id="{gradient_prefix}link-standard" x1="0%" y1="0%" x2="100%" y2="0%">'
36
+ f'<stop offset="0%" stop-color="{theme.link_standard[0]}"/>'
37
+ f'<stop offset="100%" stop-color="{theme.link_standard[1]}"/>'
38
+ "</linearGradient>"
39
+ f'<linearGradient id="{gradient_prefix}link-poe" x1="0%" y1="0%" x2="100%" y2="0%">'
40
+ f'<stop offset="0%" stop-color="{theme.link_poe[0]}"/>'
41
+ f'<stop offset="100%" stop-color="{theme.link_poe[1]}"/>'
42
+ "</linearGradient>"
43
+ f'<linearGradient id="{node_prefix}gateway" x1="0%" y1="0%" x2="100%" y2="100%">'
44
+ f'<stop offset="0%" stop-color="{theme.node_gateway[0]}"/>'
45
+ f'<stop offset="100%" stop-color="{theme.node_gateway[1]}"/>'
46
+ "</linearGradient>"
47
+ f'<linearGradient id="{node_prefix}switch" x1="0%" y1="0%" x2="100%" y2="100%">'
48
+ f'<stop offset="0%" stop-color="{theme.node_switch[0]}"/>'
49
+ f'<stop offset="100%" stop-color="{theme.node_switch[1]}"/>'
50
+ "</linearGradient>"
51
+ f'<linearGradient id="{node_prefix}ap" x1="0%" y1="0%" x2="100%" y2="100%">'
52
+ f'<stop offset="0%" stop-color="{theme.node_ap[0]}"/>'
53
+ f'<stop offset="100%" stop-color="{theme.node_ap[1]}"/>'
54
+ "</linearGradient>"
55
+ f'<linearGradient id="{node_prefix}client" x1="0%" y1="0%" x2="100%" y2="100%">'
56
+ f'<stop offset="0%" stop-color="{theme.node_client[0]}"/>'
57
+ f'<stop offset="100%" stop-color="{theme.node_client[1]}"/>'
58
+ "</linearGradient>"
59
+ f'<linearGradient id="{node_prefix}other" x1="0%" y1="0%" x2="100%" y2="100%">'
60
+ f'<stop offset="0%" stop-color="{theme.node_other[0]}"/>'
61
+ f'<stop offset="100%" stop-color="{theme.node_other[1]}"/>'
62
+ "</linearGradient>"
63
+ "</defs>"
64
+ )
@@ -0,0 +1,90 @@
1
+ """Theme loading for Mermaid and SVG rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ from .mermaid_theme import DEFAULT_THEME as DEFAULT_MERMAID_THEME
10
+ from .mermaid_theme import MermaidTheme
11
+ from .svg_theme import DEFAULT_THEME as DEFAULT_SVG_THEME
12
+ from .svg_theme import SvgTheme
13
+
14
+
15
+ def _coerce_pair(value: object, default: tuple[str, str]) -> tuple[str, str]:
16
+ if isinstance(value, list | tuple) and len(value) == 2:
17
+ left, right = value
18
+ if isinstance(left, str) and isinstance(right, str):
19
+ return (left, right)
20
+ if isinstance(value, dict):
21
+ left = value.get("from") or value.get("start")
22
+ right = value.get("to") or value.get("end")
23
+ if isinstance(left, str) and isinstance(right, str):
24
+ return (left, right)
25
+ return default
26
+
27
+
28
+ def _coerce_color(value: object, default: str) -> str:
29
+ return value if isinstance(value, str) else default
30
+
31
+
32
+ def _mermaid_theme_from_dict(data: dict, base: MermaidTheme) -> MermaidTheme:
33
+ nodes = data.get("nodes", {}) if isinstance(data.get("nodes"), dict) else {}
34
+
35
+ def _node(name: str) -> tuple[str, str]:
36
+ return (
37
+ _coerce_color(nodes.get(name, {}).get("fill"), getattr(base, f"node_{name}")[0]),
38
+ _coerce_color(nodes.get(name, {}).get("stroke"), getattr(base, f"node_{name}")[1]),
39
+ )
40
+
41
+ return MermaidTheme(
42
+ node_gateway=_node("gateway"),
43
+ node_switch=_node("switch"),
44
+ node_ap=_node("ap"),
45
+ node_client=_node("client"),
46
+ node_other=_node("other"),
47
+ poe_link=_coerce_color(data.get("poe_link"), base.poe_link),
48
+ poe_link_width=int(data.get("poe_link_width", base.poe_link_width)),
49
+ poe_link_arrow=_coerce_color(data.get("poe_link_arrow"), base.poe_link_arrow),
50
+ standard_link=_coerce_color(data.get("standard_link"), base.standard_link),
51
+ standard_link_width=int(data.get("standard_link_width", base.standard_link_width)),
52
+ standard_link_arrow=_coerce_color(
53
+ data.get("standard_link_arrow"), base.standard_link_arrow
54
+ ),
55
+ )
56
+
57
+
58
+ def _svg_theme_from_dict(data: dict, base: SvgTheme) -> SvgTheme:
59
+ nodes = data.get("nodes", {}) if isinstance(data.get("nodes"), dict) else {}
60
+ links = data.get("links", {}) if isinstance(data.get("links"), dict) else {}
61
+
62
+ return SvgTheme(
63
+ link_standard=_coerce_pair(links.get("standard"), base.link_standard),
64
+ link_poe=_coerce_pair(links.get("poe"), base.link_poe),
65
+ node_gateway=_coerce_pair(nodes.get("gateway"), base.node_gateway),
66
+ node_switch=_coerce_pair(nodes.get("switch"), base.node_switch),
67
+ node_ap=_coerce_pair(nodes.get("ap"), base.node_ap),
68
+ node_client=_coerce_pair(nodes.get("client"), base.node_client),
69
+ node_other=_coerce_pair(nodes.get("other"), base.node_other),
70
+ )
71
+
72
+
73
+ def load_theme(path: str | Path) -> tuple[MermaidTheme, SvgTheme]:
74
+ theme_path = Path(path)
75
+ payload = yaml.safe_load(theme_path.read_text(encoding="utf-8"))
76
+ if not isinstance(payload, dict):
77
+ raise ValueError("Theme file must contain a YAML mapping")
78
+
79
+ mermaid_data = payload.get("mermaid", {})
80
+ svg_data = payload.get("svg", {})
81
+
82
+ mermaid_theme = _mermaid_theme_from_dict(mermaid_data, DEFAULT_MERMAID_THEME)
83
+ svg_theme = _svg_theme_from_dict(svg_data, DEFAULT_SVG_THEME)
84
+ return mermaid_theme, svg_theme
85
+
86
+
87
+ def resolve_themes(theme_file: str | Path | None) -> tuple[MermaidTheme, SvgTheme]:
88
+ if theme_file:
89
+ return load_theme(theme_file)
90
+ return DEFAULT_MERMAID_THEME, DEFAULT_SVG_THEME