"""Modern implementations of View, Node, Connection, Profile, Point, and Position.
All classes are self-contained and carry no dependency on the legacy module.
"""
import math
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Optional, cast
from ..constants import ARCHI_CATEGORY, DEFAULT_THEME
from ..element import Element, set_id
from ..enums import ArchiType
from ..exceptions import ArchimateConceptTypeError
from ..logger import log
if TYPE_CHECKING:
from ..model import Model
# ---------------------------------------------------------------------------
# Module-level helpers
# ---------------------------------------------------------------------------
def _sort_nodes(nodes: list[Any], sort: str) -> list[Any]:
s = sort.lower()
if "asc" in s:
return sorted(nodes, key=lambda x: x.w * x.h)
if "desc" in s:
return sorted(nodes, key=lambda x: x.w * x.h, reverse=True)
return nodes
def _apply_justify_right(nodes: list[Any], max_in_row: int, max_w: float, gap_x: float) -> None:
remainder = len(nodes) % max_in_row
right_start = len(nodes) - remainder
for i in range(len(nodes), right_start, -1):
_e = nodes[i - 1]
_e.rx = max_w - _e.w - gap_x
max_w = _e.rx
if max_in_row == 1:
for _e in nodes:
_e.rx = max_w - _e.w - gap_x
def _next_row_x(justify: str, row: int, elem_w: float, gap_x: float) -> int:
if justify == "center":
return 40 if row % 2 == 0 else 40 + int((elem_w + gap_x) / 2)
return 40
def _classify_outer_quadrant(angle: float) -> str:
if 135 <= angle < 225:
return "R"
if 225 <= angle < 315:
return "B"
if angle >= 315 or angle < 45:
return "L"
return "T"
# ---------------------------------------------------------------------------
# Colour helper
# ---------------------------------------------------------------------------
[docs]
def default_color(elem_type: str, theme: "str | dict[str, str] | None" = DEFAULT_THEME) -> str:
"""Return the default fill colour for a node, keyed by Archimate element type."""
_archi_colors = {
"strategy": "#F5DEAA",
"business": "#FFFFB5",
"application": "#B5FFFF",
"technology": "#C9E7B7",
"physical": "#C9E7B7",
"migration": "#FFE0E0",
"motivation": "#CCCCFF",
"relationship": "#DDDDDD",
"other": "#FFFFFF",
"junction": "#000000",
}
_aris_colors = {
"strategy": "#D38300",
"business": "#F5C800",
"application": "#00A0FF",
"technology": "#6BA50E",
"physical": "#6BA50E",
"migration": "#FFE0E0",
"motivation": "#F099FF",
"relationship": "#DDDDDD",
"other": "#FFFFFF",
"junction": "#000000",
}
if elem_type in ARCHI_CATEGORY:
cat = ARCHI_CATEGORY[elem_type].lower()
if theme == "archi" or theme is None:
return _archi_colors.get(cat, "#FFFFFF")
if theme == "aris":
return _aris_colors.get(cat, "#FFFFFF")
try:
theme_dict = cast("dict[str, str]", theme)
return str(theme_dict[cat])
except (KeyError, TypeError):
return _archi_colors.get(cat, "#FFFFFF")
return "#FFFFFF"
# ---------------------------------------------------------------------------
# Geometry helpers
# ---------------------------------------------------------------------------
[docs]
class Point:
"""A simple (x, y) coordinate pair stored as floats for lossless round-trips.
Archi's native format encodes bendpoints as integer offsets from element
centres; those centres can be half-integers (e.g. element height 55 â
cy = y + 27.5). Storing the resolved absolute coordinate as a float
preserves the value so that ``round(bp.x - cx)`` reproduces the original
integer offset exactly during export.
"""
def __init__(
self,
x: float = 0,
y: float = 0,
start_x: int | None = None,
start_y: int | None = None,
end_x: int | None = None,
end_y: int | None = None,
):
"""Initialize a point with absolute coordinates and optional offset info."""
self._x = max(0.0, float(x))
self._y = max(0.0, float(y))
self.idx: int = 0
self.start_x = start_x
self.start_y = start_y
self.end_x = end_x
self.end_y = end_y
@property
def x(self) -> float:
"""X coordinate."""
return self._x
@x.setter
def x(self, val: float) -> None:
"""Set X coordinate (enforced non-negative)."""
self._x = max(0, val)
@property
def y(self) -> float:
"""Y coordinate."""
return self._y
@y.setter
def y(self, val: float) -> None:
"""Set Y coordinate (enforced non-negative)."""
self._y = max(0, val)
[docs]
class Position:
"""Positional relationship between two nodes (distance, angle, orientation)."""
def __init__(self):
"""Initialize empty position descriptor."""
self.dx: float | None = None
self.dy: float | None = None
self.gap_x: float = 0
self.gap_y: float = 0
self.angle: float | None = None
self.orientation: str = ""
@property
def dist(self) -> float | None:
"""Euclidean distance between nodes."""
if self.dx is not None and self.dy is not None:
return math.sqrt(self.dx**2 + self.dy**2)
return None
# ---------------------------------------------------------------------------
# Profile
# ---------------------------------------------------------------------------
[docs]
class Profile:
"""An Archimate stereotype / specialisation profile for elements or relationships."""
def __init__(self, name=None, uuid=None, concept=None, model=None):
"""Initialize a Profile with name, concept type, and model reference."""
if not name:
raise ValueError("Name of Profile must be present.")
if not concept:
raise ValueError("concept of Profile must be specified as a class of type: Element")
if not hasattr(ArchiType, concept):
raise ArchimateConceptTypeError("'concept' argument is not an instance of 'ArchiType' class.")
if concept == "View":
raise ValueError("The concept type cannot be a View for a Profile")
self.name = name
self._uuid = set_id(uuid)
self.concept = concept
self.model: Model = cast("Model", model)
[docs]
def delete(self):
"""Remove this profile and clear all references to it from elements and relationships."""
for x in [e for e in self.model.elements if e.profile_id == self.uuid]:
x.reset_profile()
for x in [r for r in self.model.relationships if r.profile_id == self.uuid]:
x.reset_profile()
if self.uuid in self.model._profiles_dict:
del self.model._profiles_dict[self.uuid]
@property
def uuid(self) -> str:
"""Unique identifier for this profile."""
return self._uuid
# ---------------------------------------------------------------------------
# Node (forward-references View in isinstance checks â defined below)
# ---------------------------------------------------------------------------
[docs]
class Node:
"""A visual node in a View, representing an Element concept.
:param ref: Element identifier or Element object
:param x: top-left x coordinate
:param y: top-left y coordinate
:param w: width
:param h: height
:param uuid: node identifier
:param node_type: one of 'Element', 'Label', 'Container'
:param label: label text (for Label/Container nodes)
:param parent: parent View or parent Node
"""
@staticmethod
def _resolve_ref(ref: object) -> "str | None":
if ref is None:
return None
if isinstance(ref, str):
return ref
if isinstance(ref, Element):
return ref.uuid
if hasattr(ref, "uuid"):
return str(ref.uuid) # pyright: ignore[reportAttributeAccessIssue]
raise ValueError("'ref' is not an instance of 'Element' class.")
def _validate_ref(self, node_type: str, ref: "str | None") -> None:
if node_type == "Element" and ref is not None and ref not in self.model.elems_dict:
from ..logger import log
log.debug(
f'Element reference "{ref}" not found in model (may be from deleted element or external reference)'
)
if node_type == "Label" and ref is not None and ref not in self.model.labels_dict:
from ..logger import log
log.debug(f'Label reference "{ref}" not found in model')
def __init__(self, ref=None, x=0, y=0, w=120, h=55, uuid=None, node_type="Element", label=None, parent=None):
"""Initialize a visual node with position, size, and element reference."""
# parent type check is done after View is defined; accept duck-typed objects
if parent is None or not (hasattr(parent, "view") and hasattr(parent, "model")):
raise ValueError("Node class parent should be a class View or Node instance!")
self.parent: View | Node = parent
self._view: View = cast("View", parent.view)
self._model: Model = cast("Model", parent.model)
self._ref = self._resolve_ref(ref)
self._validate_ref(node_type, self._ref)
self._uuid = set_id(uuid)
self._x = int(x)
self._y = int(y)
self._w = int(w)
self._h = int(h)
self._cx = self._x + self._w / 2
self._cy = self._y + self._h / 2
self._area = self._w * self._h
self.flags = 0
self.cat = node_type
self.label = label
self.nodes_dict: dict[str, Node] = {}
self._fill_color: str | None = None
self.line_color: str | None = None
self.opacity: int | float = 100
self.lc_opacity: int | float = 100
self.font_color: str | None = None
self.font_name = "Segoe UI"
self.font_size: int | float = 9
self.text_alignment: str | None = None
self.text_position = None
self.label_expression: str | None = None
self.border_type: str | None = None
self.icon_color = None
self.gradient = None
self.image_path: str | None = None
self.image_position: int | None = None
self.image_type: int | None = None
self.image_source: bool = False
# --- lifecycle ---
def _delete_concept_from_model(self, recurse: bool) -> None:
"""Remove the underlying concept and all its view references from the model."""
e = self.concept
if e is None:
return
for n in [n for n in self.model.nodes if n.ref == e.uuid]:
n.delete(recurse)
e.delete()
[docs]
def delete(self, recurse=True, delete_from_model=False):
"""Delete this node and its related connections."""
for c in self.view.conns_dict.copy().values():
if c._source == self._uuid or c._target == self._uuid:
c.delete()
del c
for n in self.nodes_dict.copy().values():
if recurse:
n.delete()
else:
n.move(self.parent)
if self._uuid in self.parent.nodes_dict:
del self.parent.nodes_dict[self._uuid]
del self.model.nodes_dict[self._uuid]
if delete_from_model:
self._delete_concept_from_model(recurse)
[docs]
def add(self, ref=None, x=0, y=0, w=120, h=55, uuid=None, node_type="Element", label=None, nested_rel_type=None):
"""Create and return a child node embedded in this node."""
n = Node(ref, x, y, w, h, uuid, node_type, label, parent=self)
self.nodes_dict[n.uuid] = n
self.model.nodes_dict[n.uuid] = n
if nested_rel_type is not None:
self.model.add_relationship(rel_type=nested_rel_type, source=self.concept, target=n.concept)
return n
# --- identity ---
@property
def uuid(self) -> str:
"""Unique identifier for this node."""
return self._uuid
# --- concept proxy ---
@property
def name(self) -> str | None:
"""Name of referenced element (if Element node)."""
if self.cat == "Element" and self.concept:
return self.concept.name
return None
@property
def desc(self) -> str | None:
"""Description of referenced element."""
return self.concept.desc if self.concept else None
@property
def type(self) -> str | None:
"""ArchiMate type of referenced element."""
if self.cat == "Element" and self.concept:
return self.concept.type
return None
@property
def concept(self) -> Element | None:
"""Referenced Element object."""
try:
return cast(Element, self.model.elems_dict[self._ref])
except KeyError:
from ..logger import log
log.debug(f'Element reference "{self._ref}" not found in model')
return None
@property
def ref(self) -> str | None:
"""Element reference identifier."""
return self._ref
@ref.setter
def ref(self, ref: "str | Element | None") -> None:
"""Set element reference (updates if valid)."""
if isinstance(ref, Element):
new_ref: str | None = ref.uuid
elif ref is None:
new_ref = None
elif hasattr(ref, "uuid"):
new_ref = str(ref.uuid) # pyright: ignore[reportAttributeAccessIssue]
else:
new_ref = cast(str | None, ref)
if new_ref is not None and new_ref in self.model.elems_dict:
self._ref = new_ref
# --- position ---
@property
def x(self) -> int:
"""Top-left x coordinate."""
return self._x
@x.setter
def x(self, val: int) -> None:
"""Set x coordinate and update all child nodes proportionally."""
if val < 0:
val = 0
for n in self.nodes:
n.x += val - self._x
self._x = int(val)
self._cx = self._x + self.w / 2
@property
def cx(self) -> float:
"""Center x coordinate."""
return float(self._cx)
@cx.setter
def cx(self, val: float) -> None:
"""Set center x coordinate."""
if val < 0:
val = 0
self.x = int(val - self._w / 2 + 0.5)
self._cx = val
@property
def rx(self) -> float:
"""Relative x coordinate (relative to parent)."""
if isinstance(self.parent, Node):
return self._x - self.parent.x
return self._x
@rx.setter
def rx(self, value: float) -> None:
"""Set relative x coordinate (relative to parent)."""
if value < 0:
value = 0
if isinstance(self.parent, Node):
self.x = int(self.parent.x + value)
else:
self.x = int(value)
@property
def y(self) -> int:
"""Top-left y coordinate."""
return self._y
@y.setter
def y(self, val: int) -> None:
"""Set y coordinate and update all child nodes proportionally."""
if val < 0:
val = 0
for n in self.nodes:
n.y += val - self._y
self._y = int(val)
self._cy = self._y + self.h / 2
@property
def cy(self) -> float:
"""Center y coordinate."""
return float(self._cy)
@cy.setter
def cy(self, val: float) -> None:
"""Set center y coordinate."""
if val < 0:
val = 0
self.y = int(val - self._h / 2 + 0.5)
self._cy = val
@property
def ry(self) -> float:
"""Relative y coordinate (relative to parent)."""
if isinstance(self.parent, Node):
return self._y - self.parent.y
return self._y
@ry.setter
def ry(self, value: float) -> None:
"""Set relative y coordinate (relative to parent)."""
if value < 0:
value = 0
if isinstance(self.parent, Node):
self.y = int(self.parent.y + value)
else:
self.y = int(value)
@property
def w(self) -> int:
"""Width."""
return self._w
@w.setter
def w(self, value: int) -> None:
"""Set width and update center x."""
self._w = int(value)
self._cx = self._x + self.w / 2
@property
def h(self) -> int:
"""Height."""
return self._h
@h.setter
def h(self, value: int) -> None:
"""Set height and update center y."""
self._h = int(value)
self._cy = self._y + self.h / 2
# --- styling ---
@property
def fill_color(self) -> str | None:
"""Fill color in hex format."""
return self._fill_color
@fill_color.setter
def fill_color(self, color_str: str | None) -> None:
"""Set fill color (None resets to default)."""
if color_str is None:
self._fill_color = default_color(self.type or "", self.model.theme)
else:
self._fill_color = color_str
# --- navigation ---
@property
def view(self) -> "View":
"""Parent View."""
return self._view
@property
def model(self) -> "Model":
"""Associated Model."""
return self._model
@property
def nodes(self) -> list["Node"]:
"""Child nodes."""
return list(self.nodes_dict.values())
[docs]
def getnodes(self, elem_type: str | None = None) -> list["Node"]:
"""Get child nodes filtered by element type."""
if elem_type is None:
return list(self.nodes_dict.values())
return [x for x in self.nodes_dict.values() if x.type == elem_type]
[docs]
def get_or_create_node(
self,
elem=None,
elem_type=None,
x=0,
y=0,
w=120,
h=55,
create_elem=False,
create_node=False,
nested_rel_type=None,
):
"""Return an existing child node or create one if requested."""
_e = None
if not isinstance(elem, Element):
_e = self.model.find_elements(elem, elem_type)
if len(_e) > 0:
_e = _e[0]
elif create_elem:
_e = self.model.add(elem_type, name=elem)
else:
return None
else:
_e = elem
n = [x for x in self.nodes if x.ref == _e.uuid]
if len(n) > 0:
return n[0]
elif create_node:
return self.add(ref=_e, x=x, y=y, w=w, h=h, nested_rel_type=nested_rel_type)
return None
[docs]
def is_inside(self, x: float = 0, y: float = 0, point: Point | None = None) -> bool:
"""Return True if the (x,y) point lies within this node's bounding box."""
if point is not None:
x = float(point.x)
y = float(point.y)
return bool(self.cx - self.w / 2 < x < self.cx + self.w / 2 and self.cy - self.h / 2 < y < self.cy + self.h / 2)
[docs]
def resize(
self,
max_in_row=3,
keep_kids_size=True,
w=120,
h=55,
gap_x=20,
gap_y=20,
justify="left",
recurse=False,
sort="asc",
):
"""Resize this node to fit all embedded children."""
max_w = w
max_h = h
ba_x = 40
ba_y = 40
max_row_h = h
n = 1
nodes = _sort_nodes(self.nodes, sort)
for _e in nodes:
if recurse:
_e.resize(
max_in_row=max_in_row,
keep_kids_size=keep_kids_size,
w=w,
h=h,
gap_x=gap_x,
gap_y=gap_y,
justify=justify,
sort=sort,
)
min_w = _e.w if (w == -1 or keep_kids_size) else w
min_h = _e.h if (h == -1 or keep_kids_size) else h
_e.rx = ba_x
_e.ry = ba_y
_e.w = min_w
_e.h = min_h
max_row_h = max(max_row_h, _e.h)
ba_x += _e.w + gap_x
if n % max_in_row == 0:
ba_x = _next_row_x(justify, n // max_in_row, _e.w, gap_x)
ba_y += max_row_h + gap_y
max_row_h = h
n += 1
max_w = max(max_w, _e.rx + _e.w + gap_x)
max_h = max(max_h, _e.ry + _e.h + gap_y)
self.w = max_w
self.h = max_h
if justify == "right":
_apply_justify_right(nodes, max_in_row, max_w, gap_x)
[docs]
def conns(self, rel_type=None):
"""Return connections to/from this node, optionally filtered by type."""
if rel_type is None:
return [
c
for c in self.view.conns_dict.values()
if c.source is not None
and c.target is not None
and (c.source.uuid == self.uuid or c.target.uuid == self.uuid)
]
return [
c
for c in self.view.conns_dict.values()
if c.source is not None
and c.target is not None
and c.type == rel_type
and (c.source.uuid == self.uuid or c.target.uuid == self.uuid)
]
[docs]
def in_conns(self, rel_type: str | None = None) -> list["Connection"]:
"""Incoming connections (this node as target), optionally filtered by type."""
if rel_type is None:
return [c for c in self.view.conns_dict.values() if c.target is not None and c.target.uuid == self.uuid]
return [
c
for c in self.view.conns_dict.values()
if c.target is not None and c.type == rel_type and c.target.uuid == self.uuid
]
[docs]
def out_conns(self, rel_type: str | None = None) -> list["Connection"]:
"""Outgoing connections (this node as source), optionally filtered by type."""
if rel_type is None:
return [c for c in self.view.conns_dict.values() if c.source is not None and c.source.uuid == self.uuid]
return [
c
for c in self.view.conns_dict.values()
if c.source is not None and c.type == rel_type and c.source.uuid == self.uuid
]
def _compute_gap_x(self, other_node: "Node") -> float:
ocx, ow = float(other_node.cx), float(other_node.w)
scx, sx, sw = float(self.cx), float(self.x), float(self.w)
if ocx - ow / 2 > sx + sw / 2 and ocx + ow / 2 > scx + sw / 2:
return ocx - ow / 2 - scx - sw / 2
if ocx - ow / 2 < scx - sw / 2 and ocx + ow / 2 < scx - sw / 2:
return ocx + ow / 2 - scx + sw / 2
return 0.0
def _compute_gap_y(self, other_node: "Node") -> float:
ocy, oh = float(other_node.cy), float(other_node.h)
scy, sh = float(self.cy), float(self.h)
if ocy - oh / 2 > scy + sh / 2 and ocy + oh / 2 > scy + sh / 2:
return ocy - oh / 2 - scy - sh / 2
if ocy - oh / 2 < scy - sh / 2 and ocy + oh / 2 < scy - sh / 2:
return ocy + oh / 2 - scy + sh / 2
return 0.0
@staticmethod
def _refine_gap_orientation(position: Position) -> None:
if position.gap_x == 0 and position.gap_y < 0:
position.orientation = "T!"
elif position.gap_x == 0 and position.gap_y > 0:
position.orientation = "B!"
elif position.gap_x < 0 and position.gap_y == 0:
position.orientation = "L!"
elif position.gap_x > 0 and position.gap_y == 0:
position.orientation = "R!"
[docs]
def get_obj_pos(self, other_node: "Node") -> Position:
"""Return a Position describing this node's relationship to another."""
dx = other_node.cx - self.cx
dy = other_node.cy - self.cy
position = Position()
position.dx = dx
position.dy = dy
angle = math.atan2(float(dy), float(dx)) * 180 / math.pi
if angle < 0:
angle += 360
angle = (360 - angle) % 360
position.angle = angle
if angle < 45 or angle > 315:
position.orientation = "R"
elif 45 <= angle < 135:
position.orientation = "T"
elif 135 <= angle < 225:
position.orientation = "L"
else:
position.orientation = "B"
position.gap_x = self._compute_gap_x(other_node)
position.gap_y = self._compute_gap_y(other_node)
self._refine_gap_orientation(position)
return position
[docs]
def get_point_pos(self, point: Point) -> Position:
"""Return positional relationship between this node and a point."""
position = Position()
dx = float(point.x - self.cx)
dy = float(point.y - self.cy)
position.dx = dx
position.dy = dy
position.gap_x = (abs(dx) - self.w / 2) * (1 if dx > 0 else -1)
position.gap_y = (abs(dy) - self.h / 2) * (1 if dy > 0 else -1)
angle = math.atan2(dy, dx) * 180 / math.pi
if angle < 0:
angle += 360
angle = (360 - angle) % 360
position.angle = angle
in_y_band = self.cy - self.h / 2 < point.y < self.cy + self.h / 2
in_x_band = self.cx - self.w / 2 < point.x < self.cx + self.w / 2
if not in_y_band and not in_x_band:
pos = _classify_outer_quadrant(angle)
elif (angle > 270 or angle < 90) and in_y_band:
pos = "L!"
elif angle > 180 and in_x_band:
pos = "B!"
elif angle < 180 and in_x_band:
pos = "T!"
elif in_y_band:
pos = "R!"
else:
pos = "*"
position.orientation = pos
return position
@staticmethod
def _compute_midpoint(obj1: "Node", obj2: "Node", pos: Position) -> "tuple[float, float]":
p = pos.orientation
cx1, cy1 = float(obj1.cx), float(obj1.cy)
cx2, cy2 = float(obj2.cx), float(obj2.cy)
w1, h1 = float(obj1.w), float(obj1.h)
if p == "L!":
return cx1 - w1 / 2 + pos.gap_x / 2, cy1
if p == "R!":
return cx1 + w1 / 2 + pos.gap_x / 2, cy1
if p == "B!":
return cx1, cy1 + h1 / 2 + pos.gap_y / 2
if p == "T!":
return cx1, cy2 + float(obj2.h) / 2 - pos.gap_y / 2
return (cx2 + cx1) / 2, (cy2 + cy1) / 2
def _queue_connection_bp(
self,
r: "Connection",
obj1: "Node",
obj2: "Node",
top: "list[dict[str, Any]]",
bottom: "list[dict[str, Any]]",
left: "list[dict[str, Any]]",
right: "list[dict[str, Any]]",
) -> None:
bps = r.get_all_bendpoints()
pos = obj1.get_obj_pos(obj2)
angle: float = pos.angle or 0.0
if obj1.is_inside(obj2.cx, obj2.cy):
return
if len(bps) == 0:
_x, _y = self._compute_midpoint(obj1, obj2, pos)
r.add_bendpoint(Point(_x, _y))
bps = r.get_all_bendpoints()
if r.target is not None and r.target.uuid == self.uuid:
bp = bps[len(bps) - 1]
bp.idx = len(bps) - 1
else:
bp = bps[0]
bp.idx = 0
bp_pos = obj1.get_point_pos(bp)
if "R" in bp_pos.orientation:
right.append({"order": angle, "bp": bp, "r": r})
if "L" in bp_pos.orientation:
left.append({"order": -((angle + 180) % 360), "bp": bp, "r": r})
if "T" in bp_pos.orientation:
top.append({"order": -angle, "bp": bp, "r": r})
if "B" in bp_pos.orientation:
bottom.append({"order": angle, "bp": bp, "r": r})
@staticmethod
def _spread_connections_along_edge(obj1: "Node", items: "list[dict[str, Any]]", axis: str) -> None:
n = len(items)
for i, entry in enumerate(sorted(items, key=lambda d: d["order"]), start=1):
if axis == "y":
entry["bp"].y = obj1.cy - obj1.h * (0.5 - (i / (n + 1)))
else:
entry["bp"].x = obj1.cx - obj1.w * (0.5 - (i / (n + 1)))
entry["r"].set_bendpoint(Point(entry["bp"].x, entry["bp"].y), entry["bp"].idx)
[docs]
def distribute_connections(self):
"""Redistribute all connections evenly along each edge of this node."""
top: list[dict[str, Any]] = []
bottom: list[dict[str, Any]] = []
left: list[dict[str, Any]] = []
right: list[dict[str, Any]] = []
obj1 = None
for r in self.conns():
if r.target is not None and r.target.uuid == self.uuid:
obj2, obj1 = r.source, r.target
else:
obj1, obj2 = r.source, r.target
if obj1 is None or obj2 is None:
continue
self._queue_connection_bp(r, obj1, obj2, top, bottom, left, right)
if obj1 is None:
return
self._spread_connections_along_edge(obj1, right, "y")
self._spread_connections_along_edge(obj1, left, "y")
self._spread_connections_along_edge(obj1, top, "x")
self._spread_connections_along_edge(obj1, bottom, "x")
[docs]
def move(self, new_parent):
"""Reparent this node to a different Node or View within the same diagram."""
if not (hasattr(new_parent, "view") and hasattr(new_parent, "model")):
log.error("Invalid target to move the node to. Expecting a View or a Node")
return
if new_parent.view.uuid != self.view.uuid:
log.error("Cannot move a node outside of its view")
return
del self.parent.nodes_dict[self.uuid]
self.parent = new_parent
new_parent.nodes_dict[self.uuid] = self
# ---------------------------------------------------------------------------
# Connection
# ---------------------------------------------------------------------------
[docs]
class Connection:
"""A visual connection between two Nodes, backed by a Relationship.
:param ref: Relationship identifier or Relationship-like object (duck-typed)
:param source: source Node identifier or Node object
:param target: target Node identifier or Node object
:param uuid: connection identifier
:param parent: parent View
"""
@staticmethod
def _resolve_conn_ref(ref: object) -> str:
if isinstance(ref, str):
return ref
if ref is not None and hasattr(ref, "uuid"):
return str(ref.uuid) # pyright: ignore[reportAttributeAccessIssue]
raise ArchimateConceptTypeError("'ref' is not an instance of 'Relationship' class.")
@staticmethod
def _resolve_node_uuid(node: object, label: str) -> str:
if isinstance(node, Node):
return node.uuid
if isinstance(node, str):
return node
raise ArchimateConceptTypeError(f"'{label}' is not an instance of 'Node' class.")
def __init__(self, ref=None, source=None, target=None, uuid=None, parent=None):
"""Initialize a visual connection between two nodes.
Args:
ref: Relationship identifier or object
source: Source node identifier or object
target: Target node identifier or object
uuid: Connection identifier
parent: Parent View
"""
if not isinstance(parent, View):
raise ArchimateConceptTypeError("Connection class parent should be a class View instance!")
self.parent: View = parent
self.view = self.parent
self._uuid = set_id(uuid)
self.model: Model = self.parent.parent
self._ref = self._resolve_conn_ref(ref)
if self._ref not in self.model.rels_dict:
from ..logger import log
log.debug(f'Relationship reference "{self._ref}" not found in model')
self._source = self._resolve_node_uuid(source, "source")
if self._source not in self.model.nodes_dict and self._source not in self.model.conns_dict:
from ..logger import log
log.debug(f'Source node reference "{self._source}" not found in model')
self._target = self._resolve_node_uuid(target, "target")
if self._target not in self.model.nodes_dict and self._target not in self.model.conns_dict:
from ..logger import log
log.debug(f'Target node reference "{self._target}" not found in model')
self._uuid = set_id(uuid)
self.bendpoints: list[Point] = []
self.line_color = None
self.font_color = None
self.font_name = "Segoe UI"
self.font_size = 9
self.line_width = 1
self.text_position = "1"
self.show_label = True
[docs]
def delete(self) -> None:
"""Remove this connection from view and model."""
del self.view.conns_dict[self.uuid]
del self.model.conns_dict[self.uuid]
@property
def uuid(self) -> str:
"""Unique identifier for this connection."""
return self._uuid
@property
def ref(self) -> str:
"""Relationship reference identifier."""
return self._ref
@ref.setter
def ref(self, ref: "str | object") -> None:
"""Set relationship reference (updates if valid)."""
if isinstance(ref, str):
new_ref = ref
elif hasattr(ref, "uuid"):
new_ref = str(ref.uuid) # pyright: ignore[reportAttributeAccessIssue]
else:
new_ref = cast(str, ref)
if new_ref in self.model.rels_dict:
self._ref = new_ref
@property
def concept(self):
"""Referenced Relationship object."""
return self.model.rels_dict[self._ref]
@property
def type(self) -> str:
"""ArchiMate relationship type."""
return cast(str, self.model.rels_dict[self._ref].type)
@property
def name(self) -> str | None:
"""Relationship name."""
return cast(str | None, self.model.rels_dict[self._ref].name)
@property
def source(self) -> Node | None:
"""Source node (None if deleted)."""
if self._source in self.model.nodes_dict:
return cast(Node, self.model.nodes_dict[self._source])
elif self._source in self.model.conns_dict:
return cast(Node, self.model.conns_dict[self._source])
return None
@source.setter
def source(self, elem: "Node | str | object") -> None:
"""Set source node (updates if valid)."""
if isinstance(elem, Node):
new_ref = elem.uuid
elif isinstance(elem, str):
new_ref = elem
elif hasattr(elem, "uuid"):
new_ref = str(elem.uuid) # pyright: ignore[reportAttributeAccessIssue]
else:
new_ref = cast(str, elem)
if new_ref in self.model.nodes_dict:
self._source = new_ref
@property
def target(self) -> Node | None:
"""Target node (None if deleted)."""
if self._target in self.model.nodes_dict:
return cast(Node, self.model.nodes_dict[self._target])
elif self._target in self.model.conns_dict:
return cast(Node, self.model.conns_dict[self._target])
return None
@target.setter
def target(self, elem: "Node | str | object") -> None:
"""Set target node (updates if valid)."""
if isinstance(elem, Node):
new_ref = elem.uuid
elif isinstance(elem, str):
new_ref = elem
elif hasattr(elem, "uuid"):
new_ref = str(elem.uuid) # pyright: ignore[reportAttributeAccessIssue]
else:
new_ref = cast(str, elem)
if new_ref in self.model.nodes_dict:
self._target = new_ref
@property
def access_type(self) -> str | None:
"""Access type (for Access relationships)."""
return cast(str | None, getattr(self.concept, "access_type", None))
@property
def is_directed(self) -> bool:
"""Whether relationship is directed."""
return cast(bool, getattr(self.concept, "is_directed", False))
@property
def influence_strength(self) -> str | None:
"""Influence strength (for Influence relationships)."""
return cast(str | None, getattr(self.concept, "influence_strength", None))
[docs]
def add_bendpoint(self, *bendpoints: Point) -> None:
"""Add one or more bendpoints to this connection."""
for bp in bendpoints:
self.bendpoints.append(bp)
[docs]
def set_bendpoint(self, bp: Point, index: int) -> None:
"""Replace bendpoint at specified index."""
if index < len(self.bendpoints):
self.bendpoints[index] = bp
[docs]
def get_bendpoint(self, index: int) -> Point | None:
"""Get bendpoint at specified index."""
return self.bendpoints[index] if index < len(self.bendpoints) else None
[docs]
def del_bendpoint(self, index: int) -> None:
"""Delete bendpoint at specified index."""
del self.bendpoints[index]
[docs]
def get_all_bendpoints(self) -> list[Point]:
"""Get all bendpoints."""
return self.bendpoints
[docs]
def remove_all_bendpoints(self) -> None:
"""Remove all bendpoints."""
self.bendpoints = []
[docs]
def l_shape(self, direction=0, weight_x=0.5, weight_y=0.5):
"""Shape the connection as an L (one bendpoint)."""
assert self.source is not None and self.target is not None
self.remove_all_bendpoints()
s_cx, s_cy = self.source.cx, self.source.cy
t_cx, t_cy = self.target.cx, self.target.cy
if direction == 0 and not self.source.is_inside(t_cx, s_cy) and not self.target.is_inside(t_cx, s_cy):
self.add_bendpoint(Point(t_cx + self.target.w * (0.5 - weight_x), s_cy + self.source.h * (0.5 - weight_y)))
elif direction == 1 and not self.source.is_inside(s_cx, t_cy) and not self.target.is_inside(s_cx, t_cy):
self.add_bendpoint(Point(s_cx - self.source.w * (0.5 - weight_x), t_cy + self.target.h * (0.5 - weight_y)))
[docs]
def s_shape(self, direction=0, weight_x=0.5, weight_y=0.5, weight2=0.5):
"""Shape the connection as an S (two bendpoints)."""
assert self.source is not None and self.target is not None
self.remove_all_bendpoints()
s_xy = Point(self.source.cx, self.source.cy)
t_xy = Point(self.target.cx, self.target.cy)
dx = t_xy.x - s_xy.x
dy = t_xy.y - s_xy.y
if direction == 0:
bp1 = Point(s_xy.x + dx * weight_x, s_xy.y - self.source.h * (0.5 - weight_y))
bp2 = Point(bp1.x, t_xy.y - self.target.h * (0.5 - weight2))
else:
bp1 = Point(s_xy.x - self.source.w * (0.5 - weight_x), s_xy.y + dy * weight_y)
bp2 = Point(t_xy.x - self.target.w * (0.5 - weight2), bp1.y)
if (
not self.source.is_inside(point=bp1)
and not self.target.is_inside(point=bp1)
and not self.target.is_inside(point=bp2)
):
self.add_bendpoint(bp1)
self.add_bendpoint(bp2)
# ---------------------------------------------------------------------------
# View
# ---------------------------------------------------------------------------
[docs]
class View:
"""A diagram (view) in an Archimate model containing Nodes and Connections.
:param name: view name
:param uuid: view identifier
:param desc: description
:param folder: folder path for organisation hierarchy
:param parent: parent Model object (duck-typed: must have views_dict)
"""
def __init__(self, name=None, uuid=None, desc=None, folder=None, parent=None):
"""Initialize a diagram view with name, description, and parent model."""
if not hasattr(parent, "views_dict"):
raise ArchimateConceptTypeError("View class parent should be a class Model instance!")
self.parent: Model = cast("Model", parent)
self.model: Model = cast("Model", parent)
self._uuid = set_id(uuid)
self.name = name
self.desc = desc
self.unions: list[object] = []
self.nodes_dict: dict[str, Node] = defaultdict(Node)
self.conns_dict: dict[str, Connection] = defaultdict(Connection)
self._properties: dict[str, object] = {}
self.folder = folder
self._primary_viewpoint: str | None = None
@property
def view(self) -> "View":
"""Reference to self (for API compatibility)."""
return self
[docs]
def delete(self) -> None:
"""Remove this view and all its nodes and connections."""
_id = self.uuid
for n in self.nodes_dict.copy().values():
n.delete(recurse=True)
del n
for c in self.conns_dict.copy().values():
c.delete()
del c
if _id in self.parent.views_dict:
del self.parent.views_dict[_id]
def _duplicate_nodes(self, dup_view: "View") -> "dict[str, Node]":
"""Copy all nodes from this view into dup_view; return original-uuid â new-node map."""
node_map: dict[str, Node] = {}
for node in self.nodes:
dup_node = dup_view.add(ref=node.ref, x=node.x, y=node.y, w=node.w, h=node.h, label=node.label)
node_map[node.uuid] = dup_node
return node_map
def _copy_connection_bendpoints(self, conn: "Connection", dup_conn: "Connection") -> None:
"""Copy all bendpoints from conn into dup_conn."""
for bp in conn.bendpoints:
dup_conn.add_bendpoint(bp)
def _duplicate_connections(self, dup_view: "View", node_map: "dict[str, Node]") -> None:
"""Copy all connections from this view into dup_view using node_map for endpoints."""
for conn in self.conns:
src_node = node_map.get(conn.source.uuid) if conn.source else None
tgt_node = node_map.get(conn.target.uuid) if conn.target else None
if not (src_node and tgt_node):
continue
dup_conn = dup_view.add_connection(ref=conn.ref, source=src_node, target=tgt_node)
if conn.bendpoints:
self._copy_connection_bendpoints(conn, dup_conn)
[docs]
def duplicate(self, name: str | None = None) -> "View":
"""Create independent deep copy of this view registered in same model.
Args:
name: Name for duplicated view. If None, appends " (copy)" to original name.
Returns:
New View object with deep-copied nodes and connections.
Raises:
ValueError: If view has no parent model (cannot register duplicate).
"""
if self.model is None:
raise ValueError("View has no parent model; cannot register duplicate")
dup_name = name if name is not None else f"{self.name} (copy)"
dup_view = View(name=dup_name, parent=self.model)
self.model.views_dict[dup_view.uuid] = dup_view
node_map = self._duplicate_nodes(dup_view)
self._duplicate_connections(dup_view, node_map)
return dup_view
[docs]
def add(
self,
ref: object = None,
x: int = 0,
y: int = 0,
w: int = 120,
h: int = 55,
uuid: str | None = None,
node_type: str = "Element",
label: str | None = None,
) -> Node:
"""Add and return a Node in this view."""
n = Node(ref, x, y, w, h, uuid, node_type, label, self)
self.nodes_dict[n.uuid] = n
self.model.nodes_dict[n.uuid] = n
return n
[docs]
def add_connection(
self, ref: object = None, source: object = None, target: object = None, uuid: str | None = None
) -> Connection:
"""Add and return a Connection between two Nodes."""
c = Connection(ref, source, target, uuid, self)
self.conns_dict[c.uuid] = c
self.model.conns_dict[c.uuid] = c
return c
@property
def uuid(self) -> str:
"""Unique identifier for this view."""
return self._uuid
@property
def type(self) -> str:
"""Type identifier (always 'Diagram')."""
return "Diagram"
@property
def props(self) -> dict[str, object]:
"""Custom properties dictionary."""
return self._properties
[docs]
def prop(self, key: str, value: object = None) -> object:
"""Get or set a custom property."""
if value is None:
return self._properties.get(key)
self._properties[key] = value
return value
[docs]
def remove_prop(self, key: str) -> None:
"""Remove a custom property."""
if key in self._properties:
del self._properties[key]
@property
def nodes(self) -> list[Node]:
"""All nodes in this view."""
return list(self.nodes_dict.values())
@property
def conns(self) -> list[Connection]:
"""All connections in this view."""
return list(self.conns_dict.values())
[docs]
def remove_folder(self) -> None:
"""Clear the folder path."""
self.folder = None
@property
def primary_viewpoint(self) -> str | None:
"""Return the primary viewpoint slug for this view.
:return: canonical viewpoint slug or None
:rtype: str | None
"""
return self._primary_viewpoint
[docs]
def set_primary_viewpoint(self, viewpoint_id: str) -> None:
"""Set the primary viewpoint slug for this view.
:param viewpoint_id: canonical viewpoint slug (e.g. 'technology')
:type viewpoint_id: str
:raises ValueError: if viewpoint_id is not a recognised slug
"""
from ..viewpoint_registry import (
validate_viewpoint_slug, # noqa: PLC0415 # deferred: avoids circular import at module load time
)
validate_viewpoint_slug(viewpoint_id)
self._primary_viewpoint = viewpoint_id
if self.model is not None:
self.model._viewpoint_views[self._uuid] = viewpoint_id
[docs]
def get_or_create_node(
self,
elem: object = None,
elem_type: str | None = None,
x: int = 0,
y: int = 0,
w: int = 120,
h: int = 55,
create_elem: bool = False,
create_node: bool = False,
) -> Node | None:
"""Return an existing node for the element, or create one if requested."""
_e = None
if not isinstance(elem, Element):
_e = self.model.find_elements(elem, elem_type)
if len(_e) > 0:
_e = _e[0]
elif create_elem:
_e = self.model.add(elem_type, name=elem)
else:
return None
else:
_e = elem
n = [x for x in self.nodes if x.ref == _e.uuid]
if len(n) > 0:
return n[0]
elif create_node:
return self.add(ref=_e, x=x, y=y, w=w, h=h)
return None
def _find_or_create_rel(
self, source: "Node", target: "Node", rel_type: str | None, name: str | None
) -> "Any | None":
if source.concept is None or target.concept is None:
return None
src_uuid = source.concept.uuid
tgt_uuid = target.concept.uuid
if name is None:
matches = self.model.filter_relationships(
lambda x: (
rel_type == x.type
and x.source is not None
and x.target is not None
and src_uuid == x.source.uuid
and tgt_uuid == x.target.uuid
)
)
else:
matches = self.model.filter_relationships(
lambda x: (
rel_type == x.type
and x.source is not None
and x.target is not None
and src_uuid == x.source.uuid
and tgt_uuid == x.target.uuid
and x.name == name
)
)
if matches:
return matches[0]
if rel_type is None:
return None
return self.model.add_relationship(source=source.ref, target=target.ref, rel_type=rel_type, name=name)
[docs]
def get_or_create_connection(
self,
rel: object = None,
source: Optional["Node"] = None,
target: Optional["Node"] = None,
rel_type: str | None = None,
name: str | None = None,
create_conn: bool = False,
) -> Optional["Connection"]:
"""Return an existing connection or create one if requested."""
if rel is None:
if source is None or target is None:
return None
r = self._find_or_create_rel(source, target, rel_type, name)
if r is None:
return None
elif hasattr(rel, "type"): # duck-typed Relationship
r = cast(Any, rel)
rel_type = r.type
else:
return None
c = [c for c in self.conns_dict.values() if c.ref == r.uuid and c.type == rel_type]
if c:
return c[0]
if create_conn and target is not None and source is not None and target.parent.uuid != source.uuid:
return self.add_connection(r, source, target)
return None
[docs]
def to_svg(self, filepath: str | None = None) -> str:
"""Export view to SVG string and optionally write to file.
Args:
filepath: Optional path to write SVG file to. If provided, SVG is
written to this file path. If None, only the SVG string
is returned.
Returns:
SVG string (valid XML with <svg> root element)
"""
from .layout.export import SVGExportService
service = SVGExportService()
return service.to_svg(self, filepath)
__all__ = ["View", "Node", "Connection", "Profile", "Point", "Position", "default_color"]