unifi-network-maps 1.4.0__py3-none-any.whl → 1.4.2__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.
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import base64
6
6
  import math
7
+ from collections.abc import Callable
7
8
  from dataclasses import dataclass
8
9
  from pathlib import Path
9
10
 
@@ -38,6 +39,17 @@ class IsoLayout:
38
39
  extra_pad: float
39
40
 
40
41
 
42
+ @dataclass(frozen=True)
43
+ class IsoLayoutPositions:
44
+ layout: IsoLayout
45
+ grid_positions: dict[str, tuple[float, float]]
46
+ positions: dict[str, tuple[float, float]]
47
+ width: float
48
+ height: float
49
+ offset_x: float
50
+ offset_y: float
51
+
52
+
41
53
  def _iso_layout(options: SvgOptions) -> IsoLayout:
42
54
  tile_width = options.node_width * 1.5
43
55
  iso_angle = math.radians(30.0)
@@ -112,14 +124,13 @@ def _compact_edge_label(
112
124
  ) -> str:
113
125
  if "<->" not in label:
114
126
  return label
115
- left, right = (part.strip() for part in label.split("<->", 1))
116
- left_name = _extract_device_name(left)
117
- right_name = _extract_device_name(right)
118
- left_port = _extract_port_text(left)
119
- right_port = _extract_port_text(right)
127
+ left_segment, right_segment = (part.strip() for part in label.split("<->", 1))
128
+ left_name = _extract_device_name(left_segment)
129
+ right_name = _extract_device_name(right_segment)
130
+ left_port = _extract_port_text(left_segment)
131
+ right_port = _extract_port_text(right_segment)
120
132
  if left_node and right_node:
121
133
  if right_name and right_name == left_node and left_name == right_node:
122
- left, right = right, left
123
134
  left_name, right_name = right_name, left_name
124
135
  left_port, right_port = right_port, left_port
125
136
  if left_port and right_port:
@@ -353,34 +364,59 @@ def _layout_nodes(
353
364
  return positions, width, height
354
365
 
355
366
 
356
- def _tree_layout_indices(
357
- edges: list[Edge], node_types: dict[str, str]
358
- ) -> tuple[dict[str, float], dict[str, int]]:
367
+ def _layout_nodeset(edges: list[Edge], node_types: dict[str, str]) -> set[str]:
359
368
  nodes = set(node_types.keys())
360
369
  for edge in edges:
361
370
  nodes.add(edge.left)
362
371
  nodes.add(edge.right)
372
+ return nodes
373
+
363
374
 
375
+ def _build_children_maps(
376
+ edges: list[Edge], nodes: set[str]
377
+ ) -> tuple[dict[str, list[str]], dict[str, int]]:
364
378
  children: dict[str, list[str]] = {name: [] for name in nodes}
365
379
  incoming: dict[str, int] = {name: 0 for name in nodes}
366
380
  for edge in edges:
367
381
  children[edge.left].append(edge.right)
368
382
  incoming[edge.right] = incoming.get(edge.right, 0) + 1
383
+ return children, incoming
369
384
 
385
+
386
+ def _sort_key_for_nodes(node_types: dict[str, str]) -> Callable[[str], tuple[int, str]]:
370
387
  type_order = {t: i for i, t in enumerate(_TYPE_ORDER)}
371
388
 
372
389
  def sort_key(name: str) -> tuple[int, str]:
373
390
  return (type_order.get(node_types.get(name, "other"), 99), name.lower())
374
391
 
392
+ return sort_key
393
+
394
+
395
+ def _sort_children(children: dict[str, list[str]], sort_key) -> None:
375
396
  for _parent, child_list in children.items():
376
397
  child_list.sort(key=sort_key)
377
398
 
399
+
400
+ def _resolve_roots(
401
+ nodes: set[str],
402
+ incoming: dict[str, int],
403
+ node_types: dict[str, str],
404
+ sort_key,
405
+ ) -> list[str]:
378
406
  gateways = [n for n, t in node_types.items() if t == "gateway"]
379
407
  roots = gateways if gateways else [n for n in nodes if incoming.get(n, 0) == 0]
380
408
  if not roots:
381
409
  roots = list(nodes)
382
- roots = sorted(roots, key=sort_key)
410
+ return sorted(roots, key=sort_key)
383
411
 
412
+
413
+ def _layout_positions(
414
+ nodes: set[str],
415
+ children: dict[str, list[str]],
416
+ *,
417
+ roots: list[str],
418
+ sort_key,
419
+ ) -> tuple[dict[str, float], dict[str, int]]:
384
420
  levels: dict[str, int] = {}
385
421
  positions_index: dict[str, float] = {}
386
422
  visited: set[str] = set()
@@ -415,14 +451,23 @@ def _tree_layout_indices(
415
451
 
416
452
  for root in roots:
417
453
  dfs(root, 0)
418
-
419
454
  for node in sorted(nodes, key=sort_key):
420
455
  if node not in positions_index:
421
456
  dfs(node, 0)
422
-
423
457
  return positions_index, levels
424
458
 
425
459
 
460
+ def _tree_layout_indices(
461
+ edges: list[Edge], node_types: dict[str, str]
462
+ ) -> tuple[dict[str, float], dict[str, int]]:
463
+ nodes = _layout_nodeset(edges, node_types)
464
+ children, incoming = _build_children_maps(edges, nodes)
465
+ sort_key = _sort_key_for_nodes(node_types)
466
+ _sort_children(children, sort_key)
467
+ roots = _resolve_roots(nodes, incoming, node_types, sort_key)
468
+ return _layout_positions(nodes, children, roots=roots, sort_key=sort_key)
469
+
470
+
426
471
  def render_svg(
427
472
  edges: list[Edge],
428
473
  *,
@@ -440,9 +485,37 @@ def render_svg(
440
485
  f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
441
486
  f'viewBox="0 0 {width} {height}">',
442
487
  svg_defs("", theme),
443
- f"<style>text{{font-family:Arial,Helvetica,sans-serif;font-size:{options.font_size}px;}}</style>",
488
+ (
489
+ "<style>text{font-family:Arial,Helvetica,sans-serif;font-size:"
490
+ f"{options.font_size}px;"
491
+ "}</style>"
492
+ ),
444
493
  ]
445
494
 
495
+ node_port_labels, node_port_prefix = _render_svg_edges(
496
+ lines, edges, positions, node_types, options
497
+ )
498
+ _render_svg_nodes(
499
+ lines,
500
+ positions,
501
+ node_types,
502
+ node_port_labels,
503
+ node_port_prefix,
504
+ icons,
505
+ options,
506
+ )
507
+
508
+ lines.append("</svg>")
509
+ return "\n".join(lines) + "\n"
510
+
511
+
512
+ def _render_svg_edges(
513
+ lines: list[str],
514
+ edges: list[Edge],
515
+ positions: dict[str, tuple[float, float]],
516
+ node_types: dict[str, str],
517
+ options: SvgOptions,
518
+ ) -> tuple[dict[str, str], dict[str, str]]:
446
519
  node_port_labels: dict[str, str] = {}
447
520
  node_port_prefix: dict[str, str] = {}
448
521
  for edge in edges:
@@ -469,34 +542,54 @@ def render_svg(
469
542
  f'<text x="{icon_x}" y="{icon_y}" text-anchor="middle" fill="#1e88e5" '
470
543
  f'font-size="{max(options.font_size, 10)}">⚡</text>'
471
544
  )
545
+ _record_edge_labels(edge, node_types, node_port_labels, node_port_prefix)
546
+ return node_port_labels, node_port_prefix
472
547
 
473
- if edge.label:
474
- label_text = _compact_edge_label(edge.label, left_node=edge.left, right_node=edge.right)
475
- left_type = node_types.get(edge.left, "other")
476
- right_type = node_types.get(edge.right, "other")
477
- client_node = None
478
- upstream_node = None
479
- if left_type == "client" and right_type != "client":
480
- client_node = edge.left
481
- upstream_node = edge.right
482
- elif right_type == "client" and left_type != "client":
483
- client_node = edge.right
484
- upstream_node = edge.left
485
- if client_node and upstream_node:
486
- if "<->" not in label_text:
487
- upstream_part = edge.label.split("<->", 1)[0].strip()
488
- port_text = _extract_port_text(upstream_part) or label_text
489
- upstream_name = _extract_device_name(upstream_part) or upstream_node
490
- node_port_labels.setdefault(client_node, f"{upstream_name}: {port_text}")
491
- node_port_prefix.setdefault(client_node, upstream_name)
492
- else:
493
- upstream_part = edge.label.split("<->", 1)[0].strip()
494
- upstream_name = _extract_device_name(upstream_part) or edge.left
495
- if label_text.lower().startswith("port "):
496
- label_text = f"{upstream_name} {label_text}"
497
- node_port_labels.setdefault(edge.right, label_text)
498
- node_port_prefix.setdefault(edge.right, upstream_name)
499
548
 
549
+ def _record_edge_labels(
550
+ edge: Edge,
551
+ node_types: dict[str, str],
552
+ node_port_labels: dict[str, str],
553
+ node_port_prefix: dict[str, str],
554
+ ) -> None:
555
+ if not edge.label:
556
+ return
557
+ label_text = _compact_edge_label(edge.label, left_node=edge.left, right_node=edge.right)
558
+ left_type = node_types.get(edge.left, "other")
559
+ right_type = node_types.get(edge.right, "other")
560
+ client_node = None
561
+ upstream_node = None
562
+ if left_type == "client" and right_type != "client":
563
+ client_node = edge.left
564
+ upstream_node = edge.right
565
+ elif right_type == "client" and left_type != "client":
566
+ client_node = edge.right
567
+ upstream_node = edge.left
568
+ if client_node and upstream_node:
569
+ if "<->" not in label_text:
570
+ upstream_part = edge.label.split("<->", 1)[0].strip()
571
+ port_text = _extract_port_text(upstream_part) or label_text
572
+ upstream_name = _extract_device_name(upstream_part) or upstream_node
573
+ node_port_labels.setdefault(client_node, f"{upstream_name}: {port_text}")
574
+ node_port_prefix.setdefault(client_node, upstream_name)
575
+ return
576
+ upstream_part = edge.label.split("<->", 1)[0].strip()
577
+ upstream_name = _extract_device_name(upstream_part) or edge.left
578
+ if label_text.lower().startswith("port "):
579
+ label_text = f"{upstream_name} {label_text}"
580
+ node_port_labels.setdefault(edge.right, label_text)
581
+ node_port_prefix.setdefault(edge.right, upstream_name)
582
+
583
+
584
+ def _render_svg_nodes(
585
+ lines: list[str],
586
+ positions: dict[str, tuple[float, float]],
587
+ node_types: dict[str, str],
588
+ node_port_labels: dict[str, str],
589
+ node_port_prefix: dict[str, str],
590
+ icons: dict[str, str],
591
+ options: SvgOptions,
592
+ ) -> None:
500
593
  for name, (x, y) in positions.items():
501
594
  node_type = node_types.get(name, "other")
502
595
  fill, stroke = _TYPE_COLORS.get(node_type, _TYPE_COLORS["other"])
@@ -539,49 +632,33 @@ def render_svg(
539
632
  f'<text x="{text_x}" y="{text_y}" fill="#1f1f1f" text-anchor="start">{safe_name}</text>'
540
633
  )
541
634
 
542
- lines.append("</svg>")
543
- return "\n".join(lines) + "\n"
544
635
 
636
+ def _iso_project(layout: IsoLayout, gx: float, gy: float) -> tuple[float, float]:
637
+ iso_x = (gx - gy) * (layout.step_width / 2)
638
+ iso_y = (gx + gy) * (layout.step_height / 2)
639
+ return iso_x, iso_y
545
640
 
546
- def render_svg_isometric(
641
+
642
+ def _iso_project_center(layout: IsoLayout, gx: float, gy: float) -> tuple[float, float]:
643
+ return _iso_project(layout, gx + 0.5, gy + 0.5)
644
+
645
+
646
+ def _iso_layout_positions(
547
647
  edges: list[Edge],
548
- *,
549
648
  node_types: dict[str, str],
550
- options: SvgOptions | None = None,
551
- theme: SvgTheme = DEFAULT_THEME,
552
- ) -> str:
553
- options = options or SvgOptions()
554
- icons = _load_isometric_icons()
555
- positions_index, levels = _tree_layout_indices(edges, node_types)
556
- if not positions_index:
557
- positions_index = {}
649
+ options: SvgOptions,
650
+ ) -> IsoLayoutPositions:
558
651
  layout = _iso_layout(options)
559
- tile_w = layout.tile_width
560
- tile_h = layout.tile_height
561
- step_w = layout.step_width
562
- step_h = layout.step_height
563
- grid_spacing_x = layout.grid_spacing_x
564
- grid_spacing_y = layout.grid_spacing_y
565
-
652
+ positions_index, levels = _tree_layout_indices(edges, node_types)
566
653
  grid_positions: dict[str, tuple[float, float]] = {}
567
654
  positions: dict[str, tuple[float, float]] = {}
568
-
569
- def project_iso(gx: float, gy: float) -> tuple[float, float]:
570
- iso_x = (gx - gy) * (step_w / 2)
571
- iso_y = (gx + gy) * (step_h / 2)
572
- return iso_x, iso_y
573
-
574
- def project_iso_center(gx: float, gy: float) -> tuple[float, float]:
575
- return project_iso(gx + 0.5, gy + 0.5)
576
-
577
655
  for name, idx in positions_index.items():
578
656
  level = levels.get(name, 0)
579
- gx = round(idx * grid_spacing_x)
580
- gy = round(float(level) * grid_spacing_y)
657
+ gx = round(idx * layout.grid_spacing_x)
658
+ gy = round(float(level) * layout.grid_spacing_y)
581
659
  grid_positions[name] = (float(gx), float(gy))
582
- iso_x, iso_y = project_iso_center(float(gx), float(gy))
660
+ iso_x, iso_y = _iso_project_center(layout, float(gx), float(gy))
583
661
  positions[name] = (iso_x, iso_y)
584
-
585
662
  if positions:
586
663
  min_x = min(x for x, _ in positions.values())
587
664
  min_y = min(y for _, y in positions.values())
@@ -590,77 +667,97 @@ def render_svg_isometric(
590
667
  else:
591
668
  min_x = min_y = 0.0
592
669
  max_x = max_y = 0.0
593
-
594
- padding = layout.padding
595
- tile_y_offset = layout.tile_y_offset
596
- offset_x = -min_x + padding
597
- offset_y = -min_y + padding + tile_y_offset
670
+ offset_x = -min_x + layout.padding
671
+ offset_y = -min_y + layout.padding + layout.tile_y_offset
598
672
  for name, (x, y) in positions.items():
599
673
  positions[name] = (x + offset_x, y + offset_y)
674
+ width = max_x - min_x + layout.tile_width + layout.padding * 2 + layout.extra_pad
675
+ height = (
676
+ max_y
677
+ - min_y
678
+ + layout.tile_height
679
+ + layout.padding * 2
680
+ + layout.tile_y_offset
681
+ + layout.extra_pad
682
+ )
683
+ return IsoLayoutPositions(
684
+ layout=layout,
685
+ grid_positions=grid_positions,
686
+ positions=positions,
687
+ width=width,
688
+ height=height,
689
+ offset_x=offset_x,
690
+ offset_y=offset_y,
691
+ )
600
692
 
601
- def project_iso_center_padded(gx: float, gy: float) -> tuple[float, float]:
602
- iso_x, iso_y = project_iso_center(gx, gy)
603
- return iso_x + offset_x, iso_y + offset_y
604
-
605
- def grid_center(gx: float, gy: float) -> tuple[float, float]:
606
- cx, cy = project_iso_center_padded(gx, gy)
607
- return cx + tile_w / 2, cy + tile_h / 2
608
-
609
- def front_anchor(gx: float, gy: float) -> tuple[float, float]:
610
- cx, cy = grid_center(gx, gy)
611
- return cx, cy
612
-
613
- width = max_x - min_x + tile_w + padding * 2 + layout.extra_pad
614
- height = max_y - min_y + tile_h + padding * 2 + tile_y_offset + layout.extra_pad
615
693
 
616
- out_width = options.width or int(width)
617
- out_height = options.height or int(height)
694
+ def _iso_grid_lines(
695
+ grid_positions: dict[str, tuple[float, float]],
696
+ layout: IsoLayout,
697
+ ) -> list[str]:
698
+ if not grid_positions:
699
+ return []
700
+ min_gx = min(gx for gx, _ in grid_positions.values())
701
+ max_gx = max(gx for gx, _ in grid_positions.values())
702
+ min_gy = min(gy for _, gy in grid_positions.values())
703
+ max_gy = max(gy for _, gy in grid_positions.values())
704
+ pad = 12
705
+ gx_start = int(math.floor(min_gx)) - pad
706
+ gx_end = int(math.ceil(max_gx)) + pad
707
+ gy_start = int(math.floor(min_gy)) - pad
708
+ gy_end = int(math.ceil(max_gy)) + pad
709
+ grid_lines: list[str] = []
710
+ for gx in range(gx_start, gx_end + 1):
711
+ x1, y1 = _iso_project(layout, float(gx), float(gy_start))
712
+ x2, y2 = _iso_project(layout, float(gx), float(gy_end))
713
+ x1 += layout.padding
714
+ y1 += layout.padding
715
+ x2 += layout.padding
716
+ y2 += layout.padding
717
+ grid_lines.append(
718
+ f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#efefef" stroke-width="0.6"/>'
719
+ )
720
+ for gy in range(gy_start, gy_end + 1):
721
+ x1, y1 = _iso_project(layout, float(gx_start), float(gy))
722
+ x2, y2 = _iso_project(layout, float(gx_end), float(gy))
723
+ x1 += layout.padding
724
+ y1 += layout.padding
725
+ x2 += layout.padding
726
+ y2 += layout.padding
727
+ grid_lines.append(
728
+ f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#efefef" stroke-width="0.6"/>'
729
+ )
730
+ return grid_lines
618
731
 
619
- lines = [
620
- f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
621
- f'viewBox="0 0 {width} {height}">',
622
- svg_defs("iso", theme),
623
- f"<style>text{{font-family:Arial,Helvetica,sans-serif;font-size:{options.font_size}px;}}</style>",
624
- ]
625
732
 
626
- if grid_positions:
627
- min_gx = min(gx for gx, _ in grid_positions.values())
628
- max_gx = max(gx for gx, _ in grid_positions.values())
629
- min_gy = min(gy for _, gy in grid_positions.values())
630
- max_gy = max(gy for _, gy in grid_positions.values())
631
- pad = 12
632
- gx_start = int(math.floor(min_gx)) - pad
633
- gx_end = int(math.ceil(max_gx)) + pad
634
- gy_start = int(math.floor(min_gy)) - pad
635
- gy_end = int(math.ceil(max_gy)) + pad
636
- grid_lines: list[str] = []
637
- for gx in range(gx_start, gx_end + 1):
638
- x1, y1 = project_iso(float(gx), float(gy_start))
639
- x2, y2 = project_iso(float(gx), float(gy_end))
640
- x1 += padding
641
- y1 += padding
642
- x2 += padding
643
- y2 += padding
644
- grid_lines.append(
645
- f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#efefef" stroke-width="0.6"/>'
646
- )
647
- for gy in range(gy_start, gy_end + 1):
648
- x1, y1 = project_iso(float(gx_start), float(gy))
649
- x2, y2 = project_iso(float(gx_end), float(gy))
650
- x1 += padding
651
- y1 += padding
652
- x2 += padding
653
- y2 += padding
654
- grid_lines.append(
655
- f'<line x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}" stroke="#efefef" stroke-width="0.6"/>'
656
- )
657
- lines.append('<g class="iso-grid" opacity="0.7">')
658
- lines.extend(grid_lines)
659
- lines.append("</g>")
733
+ def _iso_front_anchor(
734
+ layout: IsoLayout,
735
+ *,
736
+ gx: float,
737
+ gy: float,
738
+ offset_x: float,
739
+ offset_y: float,
740
+ ) -> tuple[float, float]:
741
+ iso_x, iso_y = _iso_project_center(layout, gx, gy)
742
+ cx = iso_x + offset_x + layout.tile_width / 2
743
+ cy = iso_y + offset_y + layout.tile_height / 2
744
+ return cx, cy
660
745
 
661
- node_port_labels: dict[str, str] = {}
662
- node_port_prefix: dict[str, str] = {}
663
746
 
747
+ def _render_iso_edges(
748
+ lines: list[str],
749
+ edges: list[Edge],
750
+ *,
751
+ positions: dict[str, tuple[float, float]],
752
+ grid_positions: dict[str, tuple[float, float]],
753
+ node_types: dict[str, str],
754
+ layout: IsoLayout,
755
+ options: SvgOptions,
756
+ offset_x: float,
757
+ offset_y: float,
758
+ node_port_labels: dict[str, str],
759
+ node_port_prefix: dict[str, str],
760
+ ) -> None:
664
761
  for edge in edges:
665
762
  if edge.left not in positions or edge.right not in positions:
666
763
  continue
@@ -672,210 +769,409 @@ def render_svg_isometric(
672
769
  width_px = 5 if edge.poe else 4
673
770
  src_gx, src_gy = float(src_grid[0]), float(src_grid[1])
674
771
  dst_gx, dst_gy = float(dst_grid[0]), float(dst_grid[1])
675
- dx = dst_gx - src_gx
676
- dy = dst_gy - src_gy
677
- src_cx, src_cy = front_anchor(src_gx, src_gy)
678
- dst_cx, dst_cy = front_anchor(dst_gx, dst_gy)
679
- path_cmds: list[str] = []
680
- if dx == 0 or dy == 0:
681
- path_cmds = [f"M {src_cx} {src_cy}", f"L {dst_cx} {dst_cy}"]
682
- else:
683
- elbow_gx, elbow_gy = dst_gx, src_gy
684
- elbow_cx, elbow_cy = front_anchor(elbow_gx, elbow_gy)
685
- path_cmds = [
686
- f"M {src_cx} {src_cy}",
687
- f"L {elbow_cx} {elbow_cy}",
688
- f"L {dst_cx} {dst_cy}",
689
- ]
690
- path = " ".join(path_cmds)
772
+ src_cx, src_cy = _iso_front_anchor(
773
+ layout, gx=src_gx, gy=src_gy, offset_x=offset_x, offset_y=offset_y
774
+ )
775
+ dst_cx, dst_cy = _iso_front_anchor(
776
+ layout, gx=dst_gx, gy=dst_gy, offset_x=offset_x, offset_y=offset_y
777
+ )
778
+ path_cmds = _iso_edge_path(
779
+ layout,
780
+ offset_x,
781
+ offset_y,
782
+ src_gx,
783
+ src_gy,
784
+ dst_gx,
785
+ dst_gy,
786
+ src_cx,
787
+ src_cy,
788
+ dst_cx,
789
+ dst_cy,
790
+ )
691
791
  dash = ' stroke-dasharray="8 6"' if edge.wireless else ""
692
792
  lines.append(
693
- f'<path d="{path}" stroke="{color}" stroke-width="{width_px}" '
793
+ f'<path d="{" ".join(path_cmds)}" stroke="{color}" stroke-width="{width_px}" '
694
794
  f'fill="none" stroke-linecap="round" stroke-linejoin="round"{dash}/>'
695
795
  )
696
796
  if edge.poe:
697
797
  icon_x = dst_cx
698
- icon_y = dst_cy - tile_h * 0.4
798
+ icon_y = dst_cy - layout.tile_height * 0.4
699
799
  lines.append(
700
800
  f'<text x="{icon_x}" y="{icon_y}" text-anchor="middle" fill="#1e88e5" '
701
801
  f'font-size="{max(options.font_size, 10)}">⚡</text>'
702
802
  )
703
- if edge.label:
704
- label_text = _compact_edge_label(edge.label, left_node=edge.left, right_node=edge.right)
705
- left_type = node_types.get(edge.left, "other")
706
- right_type = node_types.get(edge.right, "other")
707
- client_node = None
708
- upstream_node = None
709
- if left_type == "client" and right_type != "client":
710
- client_node = edge.left
711
- upstream_node = edge.right
712
- elif right_type == "client" and left_type != "client":
713
- client_node = edge.right
714
- upstream_node = edge.left
715
- if client_node and upstream_node:
716
- if "<->" not in label_text:
717
- upstream_part = edge.label.split("<->", 1)[0].strip()
718
- port_text = _extract_port_text(upstream_part) or label_text
719
- upstream_name = upstream_node
720
- node_port_labels.setdefault(client_node, f"{upstream_name}: {port_text}")
721
- node_port_prefix.setdefault(client_node, _shorten_prefix(upstream_name))
722
- else:
723
- upstream_part = edge.label.split("<->", 1)[0].strip()
724
- upstream_name = _extract_device_name(upstream_part) or edge.left
725
- if label_text.lower().startswith("port "):
726
- label_text = f"{upstream_name} {label_text}"
727
- node_port_labels.setdefault(edge.right, label_text)
728
- node_port_prefix.setdefault(edge.right, _shorten_prefix(edge.left))
803
+ _record_iso_edge_label(edge, node_types, node_port_labels, node_port_prefix)
804
+
805
+
806
+ def _iso_edge_path(
807
+ layout: IsoLayout,
808
+ offset_x: float,
809
+ offset_y: float,
810
+ src_gx: float,
811
+ src_gy: float,
812
+ dst_gx: float,
813
+ dst_gy: float,
814
+ src_cx: float,
815
+ src_cy: float,
816
+ dst_cx: float,
817
+ dst_cy: float,
818
+ ) -> list[str]:
819
+ dx = dst_gx - src_gx
820
+ dy = dst_gy - src_gy
821
+ if dx == 0 or dy == 0:
822
+ return [f"M {src_cx} {src_cy}", f"L {dst_cx} {dst_cy}"]
823
+ elbow_gx, elbow_gy = dst_gx, src_gy
824
+ elbow_cx, elbow_cy = _iso_front_anchor(
825
+ layout,
826
+ gx=elbow_gx,
827
+ gy=elbow_gy,
828
+ offset_x=offset_x,
829
+ offset_y=offset_y,
830
+ )
831
+ return [
832
+ f"M {src_cx} {src_cy}",
833
+ f"L {elbow_cx} {elbow_cy}",
834
+ f"L {dst_cx} {dst_cy}",
835
+ ]
729
836
 
730
- node_depth = 0.0
731
837
 
732
- for name, (x, y) in positions.items():
733
- node_type = node_types.get(name, "other")
734
- fill, stroke = _TYPE_COLORS.get(node_type, _TYPE_COLORS["other"])
735
- fill = f"url(#iso-node-{node_type})"
736
- top = [
737
- (x + tile_w / 2, y),
738
- (x + tile_w, y + tile_h / 2),
739
- (x + tile_w / 2, y + tile_h),
740
- (x, y + tile_h / 2),
741
- ]
742
- left = [
743
- (x, y + tile_h / 2),
744
- (x + tile_w / 2, y + tile_h),
745
- (x + tile_w / 2, y + tile_h + node_depth),
746
- (x, y + tile_h / 2 + node_depth),
747
- ]
748
- right = [
749
- (x + tile_w, y + tile_h / 2),
750
- (x + tile_w / 2, y + tile_h),
751
- (x + tile_w / 2, y + tile_h + node_depth),
752
- (x + tile_w, y + tile_h / 2 + node_depth),
753
- ]
754
- left_fill = "#d0d0d0" if node_type == "other" else "#dcdcdc"
755
- right_fill = "#c2c2c2" if node_type == "other" else "#c8c8c8"
756
- if node_depth > 0:
757
- lines.append(
758
- f'<polygon points="{" ".join(f"{px},{py}" for px, py in left)}" '
759
- f'fill="{left_fill}" stroke="{stroke}" stroke-width="1"/>'
760
- )
761
- lines.append(
762
- f'<polygon points="{" ".join(f"{px},{py}" for px, py in right)}" '
763
- f'fill="{right_fill}" stroke="{stroke}" stroke-width="1"/>'
764
- )
838
+ def _record_iso_edge_label(
839
+ edge: Edge,
840
+ node_types: dict[str, str],
841
+ node_port_labels: dict[str, str],
842
+ node_port_prefix: dict[str, str],
843
+ ) -> None:
844
+ if not edge.label:
845
+ return
846
+ label_text = _compact_edge_label(edge.label, left_node=edge.left, right_node=edge.right)
847
+ left_type = node_types.get(edge.left, "other")
848
+ right_type = node_types.get(edge.right, "other")
849
+ client_node = None
850
+ upstream_node = None
851
+ if left_type == "client" and right_type != "client":
852
+ client_node = edge.left
853
+ upstream_node = edge.right
854
+ elif right_type == "client" and left_type != "client":
855
+ client_node = edge.right
856
+ upstream_node = edge.left
857
+ if client_node and upstream_node:
858
+ if "<->" not in label_text:
859
+ upstream_part = edge.label.split("<->", 1)[0].strip()
860
+ port_text = _extract_port_text(upstream_part) or label_text
861
+ node_port_labels.setdefault(client_node, f"{upstream_node}: {port_text}")
862
+ node_port_prefix.setdefault(client_node, _shorten_prefix(upstream_node))
863
+ return
864
+ upstream_part = edge.label.split("<->", 1)[0].strip()
865
+ upstream_name = _extract_device_name(upstream_part) or edge.left
866
+ if label_text.lower().startswith("port "):
867
+ label_text = f"{upstream_name} {label_text}"
868
+ node_port_labels.setdefault(edge.right, label_text)
869
+ node_port_prefix.setdefault(edge.right, _shorten_prefix(edge.left))
870
+
871
+
872
+ def _iso_node_polygons(
873
+ x: float,
874
+ y: float,
875
+ tile_w: float,
876
+ tile_h: float,
877
+ node_depth: float,
878
+ ) -> tuple[list[tuple[float, float]], list[tuple[float, float]], list[tuple[float, float]]]:
879
+ top = [
880
+ (x + tile_w / 2, y),
881
+ (x + tile_w, y + tile_h / 2),
882
+ (x + tile_w / 2, y + tile_h),
883
+ (x, y + tile_h / 2),
884
+ ]
885
+ left = [
886
+ (x, y + tile_h / 2),
887
+ (x + tile_w / 2, y + tile_h),
888
+ (x + tile_w / 2, y + tile_h + node_depth),
889
+ (x, y + tile_h / 2 + node_depth),
890
+ ]
891
+ right = [
892
+ (x + tile_w, y + tile_h / 2),
893
+ (x + tile_w / 2, y + tile_h),
894
+ (x + tile_w / 2, y + tile_h + node_depth),
895
+ (x + tile_w, y + tile_h / 2 + node_depth),
896
+ ]
897
+ return top, left, right
898
+
899
+
900
+ def _iso_render_faces(
901
+ lines: list[str],
902
+ *,
903
+ top: list[tuple[float, float]],
904
+ left: list[tuple[float, float]],
905
+ right: list[tuple[float, float]],
906
+ fill: str,
907
+ stroke: str,
908
+ left_fill: str,
909
+ right_fill: str,
910
+ node_depth: float,
911
+ ) -> None:
912
+ if node_depth > 0:
913
+ lines.append(
914
+ f'<polygon points="{" ".join(f"{px},{py}" for px, py in left)}" '
915
+ f'fill="{left_fill}" stroke="{stroke}" stroke-width="1"/>'
916
+ )
765
917
  lines.append(
766
- f'<polygon points="{" ".join(f"{px},{py}" for px, py in top)}" '
767
- f'fill="{fill}" stroke="{stroke}" stroke-width="1"/>'
918
+ f'<polygon points="{" ".join(f"{px},{py}" for px, py in right)}" '
919
+ f'fill="{right_fill}" stroke="{stroke}" stroke-width="1"/>'
768
920
  )
921
+ lines.append(
922
+ f'<polygon points="{" ".join(f"{px},{py}" for px, py in top)}" '
923
+ f'fill="{fill}" stroke="{stroke}" stroke-width="1"/>'
924
+ )
769
925
 
770
- icon_href = icons.get(node_type, icons.get("other"))
771
- center_x = x + tile_w / 2
772
- center_y = y + tile_h / 2
773
- icon_center_x = center_x
774
- icon_center_y = center_y
775
- iso_icon_size = min(tile_w, tile_h) * 1.26
776
926
 
777
- port_label = node_port_labels.get(name)
778
- if port_label:
779
- font_size = max(options.font_size - 2, 8)
780
- max_chars = max(8, int((tile_w * 0.6) / (font_size * 0.6)))
781
- tile_width = tile_w
782
- tile_height = tile_h
783
- label_center_x = center_x
784
- stack_depth = tile_h / 2
785
- label_center_y = y + tile_height / 2 - stack_depth
786
- top_points = _iso_tile_points(label_center_x, label_center_y, tile_width, tile_height)
787
- tile_points = _points_to_svg(top_points)
788
- # Stack a shallow side to suggest elevation.
789
- bottom_points = [(px, py + stack_depth) for px, py in top_points]
790
- # Right face uses points 1->2 and their offset counterparts.
791
- right_face = [
792
- top_points[1],
793
- top_points[2],
794
- bottom_points[2],
795
- bottom_points[1],
796
- ]
797
- left_face = [
798
- top_points[3],
799
- top_points[2],
800
- bottom_points[2],
801
- bottom_points[3],
802
- ]
803
- lines.append(
804
- f'<polygon class="label-tile-side" points="'
805
- f'{" ".join(f"{px},{py}" for px, py in left_face)}" '
806
- f'fill="{left_fill}" stroke="{stroke}" stroke-width="1"/>'
807
- )
808
- lines.append(
809
- f'<polygon class="label-tile-side" points="'
810
- f'{" ".join(f"{px},{py}" for px, py in right_face)}" '
811
- f'fill="{right_fill}" stroke="{stroke}" stroke-width="1"/>'
812
- )
813
- label_fill = fill
814
- lines.append(
815
- f'<polygon class="label-tile" points="{tile_points}" '
816
- f'fill="{label_fill}" stroke="{stroke}" stroke-width="1"/>'
817
- )
818
- icon_center_x = label_center_x
819
- icon_center_y = label_center_y
820
- if port_label:
821
- left_edge_top = top[0]
822
- left_edge_bottom = top[3]
823
- edge_len = math.hypot(
824
- left_edge_bottom[0] - left_edge_top[0],
825
- left_edge_bottom[1] - left_edge_top[1],
826
- )
827
- max_chars = max(6, int((edge_len * 0.85) / (font_size * 0.6)))
828
- prefix = node_port_prefix.get(name, "switch")
829
- front_lines = _format_port_label_lines(
830
- port_label,
831
- node_type=node_type,
832
- prefix=prefix,
833
- max_chars=max_chars,
834
- )
835
- if front_lines:
836
- text_x, text_y, edge_angle = _iso_front_text_position(
837
- top_points, tile_w, tile_h
838
- )
839
- _render_iso_text(
840
- lines,
841
- text_x=text_x,
842
- text_y=text_y,
843
- angle=edge_angle,
844
- text_lines=front_lines,
845
- font_size=font_size,
846
- fill="#555",
847
- )
848
-
849
- if node_type == "ap":
850
- icon_center_y -= tile_h * 0.4
851
- if icon_href:
852
- icon_x = icon_center_x - iso_icon_size / 2
853
- icon_lift = tile_h * (0.02 if port_label else 0.04)
854
- icon_y = icon_center_y - iso_icon_size / 2 - icon_lift - tile_h * 0.05
855
- if node_type == "client":
856
- icon_y -= tile_h * 0.05
857
- lines.append(
858
- f'<image href="{icon_href}" x="{icon_x}" y="{icon_y}" '
859
- f'width="{iso_icon_size}" height="{iso_icon_size}" '
860
- f'preserveAspectRatio="xMidYMid meet"/>'
861
- )
862
-
863
- name_font_size = max(options.font_size - 2, 8)
864
- name_x, name_y, name_angle = _iso_name_label_position(
865
- top,
866
- tile_width=tile_w,
867
- tile_height=tile_h,
868
- font_size=name_font_size,
927
+ def _render_iso_port_label(
928
+ lines: list[str],
929
+ *,
930
+ port_label: str,
931
+ node_type: str,
932
+ prefix: str,
933
+ center_x: float,
934
+ center_y: float,
935
+ tile_w: float,
936
+ tile_h: float,
937
+ fill: str,
938
+ stroke: str,
939
+ left_fill: str,
940
+ right_fill: str,
941
+ font_size: int,
942
+ ) -> tuple[float, float]:
943
+ tile_width = tile_w
944
+ tile_height = tile_h
945
+ stack_depth = tile_h / 2
946
+ label_center_x = center_x
947
+ label_center_y = center_y - stack_depth
948
+ top_points = _iso_tile_points(label_center_x, label_center_y, tile_width, tile_height)
949
+ tile_points = _points_to_svg(top_points)
950
+ bottom_points = [(px, py + stack_depth) for px, py in top_points]
951
+ right_face = [
952
+ top_points[1],
953
+ top_points[2],
954
+ bottom_points[2],
955
+ bottom_points[1],
956
+ ]
957
+ left_face = [
958
+ top_points[3],
959
+ top_points[2],
960
+ bottom_points[2],
961
+ bottom_points[3],
962
+ ]
963
+ left_points = " ".join(f"{px},{py}" for px, py in left_face)
964
+ right_points = " ".join(f"{px},{py}" for px, py in right_face)
965
+ lines.append(
966
+ f'<polygon class="label-tile-side" points="{left_points}" '
967
+ f'fill="{left_fill}" stroke="{stroke}" stroke-width="1"/>'
968
+ )
969
+ lines.append(
970
+ f'<polygon class="label-tile-side" points="{right_points}" '
971
+ f'fill="{right_fill}" stroke="{stroke}" stroke-width="1"/>'
972
+ )
973
+ lines.append(
974
+ f'<polygon class="label-tile" points="{tile_points}" '
975
+ f'fill="{fill}" stroke="{stroke}" stroke-width="1"/>'
976
+ )
977
+ left_edge_top = top_points[0]
978
+ left_edge_bottom = top_points[3]
979
+ edge_len = math.hypot(
980
+ left_edge_bottom[0] - left_edge_top[0],
981
+ left_edge_bottom[1] - left_edge_top[1],
982
+ )
983
+ max_chars = max(6, int((edge_len * 0.85) / (font_size * 0.6)))
984
+ front_lines = _format_port_label_lines(
985
+ port_label,
986
+ node_type=node_type,
987
+ prefix=prefix,
988
+ max_chars=max_chars,
989
+ )
990
+ if front_lines:
991
+ text_x, text_y, edge_angle = _iso_front_text_position(top_points, tile_w, tile_h)
992
+ _render_iso_text(
993
+ lines,
994
+ text_x=text_x,
995
+ text_y=text_y,
996
+ angle=edge_angle,
997
+ text_lines=front_lines,
998
+ font_size=font_size,
999
+ fill="#555",
869
1000
  )
870
- name_transform = (
871
- f"translate({name_x} {name_y}) rotate({name_angle}) skewX(30) "
872
- f"translate({-name_x} {-name_y})"
1001
+ return label_center_x, label_center_y
1002
+
1003
+
1004
+ def _render_iso_node(
1005
+ lines: list[str],
1006
+ *,
1007
+ name: str,
1008
+ x: float,
1009
+ y: float,
1010
+ node_type: str,
1011
+ icons: dict[str, str],
1012
+ options: SvgOptions,
1013
+ port_label: str | None,
1014
+ port_prefix: str | None,
1015
+ layout: IsoLayout,
1016
+ ) -> None:
1017
+ fill, stroke = _TYPE_COLORS.get(node_type, _TYPE_COLORS["other"])
1018
+ fill = f"url(#iso-node-{node_type})"
1019
+ node_depth = 0.0
1020
+ tile_w = layout.tile_width
1021
+ tile_h = layout.tile_height
1022
+ top, left, right = _iso_node_polygons(x, y, tile_w, tile_h, node_depth)
1023
+ left_fill = "#d0d0d0" if node_type == "other" else "#dcdcdc"
1024
+ right_fill = "#c2c2c2" if node_type == "other" else "#c8c8c8"
1025
+ _iso_render_faces(
1026
+ lines,
1027
+ top=top,
1028
+ left=left,
1029
+ right=right,
1030
+ fill=fill,
1031
+ stroke=stroke,
1032
+ left_fill=left_fill,
1033
+ right_fill=right_fill,
1034
+ node_depth=node_depth,
1035
+ )
1036
+ icon_href = icons.get(node_type, icons.get("other"))
1037
+ center_x = x + tile_w / 2
1038
+ center_y = y + tile_h / 2
1039
+ icon_center_x = center_x
1040
+ icon_center_y = center_y
1041
+ iso_icon_size = min(tile_w, tile_h) * 1.26
1042
+ if port_label:
1043
+ font_size = max(options.font_size - 2, 8)
1044
+ prefix = port_prefix or "switch"
1045
+ icon_center_x, icon_center_y = _render_iso_port_label(
1046
+ lines,
1047
+ port_label=port_label,
1048
+ node_type=node_type,
1049
+ prefix=prefix,
1050
+ center_x=center_x,
1051
+ center_y=center_y,
1052
+ tile_w=tile_w,
1053
+ tile_h=tile_h,
1054
+ fill=fill,
1055
+ stroke=stroke,
1056
+ left_fill=left_fill,
1057
+ right_fill=right_fill,
1058
+ font_size=font_size,
873
1059
  )
1060
+ if node_type == "ap":
1061
+ icon_center_y -= tile_h * 0.4
1062
+ if icon_href:
1063
+ icon_x = icon_center_x - iso_icon_size / 2
1064
+ icon_lift = tile_h * (0.02 if port_label else 0.04)
1065
+ icon_y = icon_center_y - iso_icon_size / 2 - icon_lift - tile_h * 0.05
1066
+ if node_type == "client":
1067
+ icon_y -= tile_h * 0.05
874
1068
  lines.append(
875
- f'<text x="{name_x}" y="{name_y}" text-anchor="middle" fill="#1f1f1f" '
876
- f'font-size="{name_font_size}" transform="{name_transform}">'
877
- f"{_escape_text(name)}</text>"
1069
+ f'<image href="{icon_href}" x="{icon_x}" y="{icon_y}" '
1070
+ f'width="{iso_icon_size}" height="{iso_icon_size}" '
1071
+ f'preserveAspectRatio="xMidYMid meet"/>'
878
1072
  )
1073
+ name_font_size = max(options.font_size - 2, 8)
1074
+ name_x, name_y, name_angle = _iso_name_label_position(
1075
+ top,
1076
+ tile_width=tile_w,
1077
+ tile_height=tile_h,
1078
+ font_size=name_font_size,
1079
+ )
1080
+ name_transform = (
1081
+ f"translate({name_x} {name_y}) rotate({name_angle}) skewX(30) "
1082
+ f"translate({-name_x} {-name_y})"
1083
+ )
1084
+ lines.append(
1085
+ f'<text x="{name_x}" y="{name_y}" text-anchor="middle" fill="#1f1f1f" '
1086
+ f'font-size="{name_font_size}" transform="{name_transform}">{_escape_text(name)}</text>'
1087
+ )
1088
+
1089
+
1090
+ def _render_iso_nodes(
1091
+ lines: list[str],
1092
+ *,
1093
+ positions: dict[str, tuple[float, float]],
1094
+ node_types: dict[str, str],
1095
+ icons: dict[str, str],
1096
+ options: SvgOptions,
1097
+ layout: IsoLayout,
1098
+ node_port_labels: dict[str, str],
1099
+ node_port_prefix: dict[str, str],
1100
+ ) -> None:
1101
+ for name, (x, y) in positions.items():
1102
+ _render_iso_node(
1103
+ lines,
1104
+ name=name,
1105
+ x=x,
1106
+ y=y,
1107
+ node_type=node_types.get(name, "other"),
1108
+ icons=icons,
1109
+ options=options,
1110
+ port_label=node_port_labels.get(name),
1111
+ port_prefix=node_port_prefix.get(name),
1112
+ layout=layout,
1113
+ )
1114
+
1115
+
1116
+ def render_svg_isometric(
1117
+ edges: list[Edge],
1118
+ *,
1119
+ node_types: dict[str, str],
1120
+ options: SvgOptions | None = None,
1121
+ theme: SvgTheme = DEFAULT_THEME,
1122
+ ) -> str:
1123
+ options = options or SvgOptions()
1124
+ icons = _load_isometric_icons()
1125
+ layout_positions = _iso_layout_positions(edges, node_types, options)
1126
+ layout = layout_positions.layout
1127
+ grid_positions = layout_positions.grid_positions
1128
+ positions = layout_positions.positions
1129
+
1130
+ out_width = options.width or int(layout_positions.width)
1131
+ out_height = options.height or int(layout_positions.height)
1132
+
1133
+ lines = [
1134
+ f'<svg xmlns="http://www.w3.org/2000/svg" width="{out_width}" height="{out_height}" '
1135
+ f'viewBox="0 0 {layout_positions.width} {layout_positions.height}">',
1136
+ svg_defs("iso", theme),
1137
+ (
1138
+ "<style>text{font-family:Arial,Helvetica,sans-serif;font-size:"
1139
+ f"{options.font_size}px;"
1140
+ "}</style>"
1141
+ ),
1142
+ ]
1143
+
1144
+ grid_lines = _iso_grid_lines(grid_positions, layout)
1145
+ if grid_lines:
1146
+ lines.append('<g class="iso-grid" opacity="0.7">')
1147
+ lines.extend(grid_lines)
1148
+ lines.append("</g>")
1149
+
1150
+ node_port_labels: dict[str, str] = {}
1151
+ node_port_prefix: dict[str, str] = {}
1152
+ _render_iso_edges(
1153
+ lines,
1154
+ edges,
1155
+ positions=positions,
1156
+ grid_positions=grid_positions,
1157
+ node_types=node_types,
1158
+ layout=layout,
1159
+ options=options,
1160
+ offset_x=layout_positions.offset_x,
1161
+ offset_y=layout_positions.offset_y,
1162
+ node_port_labels=node_port_labels,
1163
+ node_port_prefix=node_port_prefix,
1164
+ )
1165
+ _render_iso_nodes(
1166
+ lines,
1167
+ positions=positions,
1168
+ node_types=node_types,
1169
+ icons=icons,
1170
+ options=options,
1171
+ layout=layout,
1172
+ node_port_labels=node_port_labels,
1173
+ node_port_prefix=node_port_prefix,
1174
+ )
879
1175
 
880
1176
  lines.append("</svg>")
881
1177
  return "\n".join(lines) + "\n"