diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index b4b29c24..e27147e9 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -26,7 +26,7 @@ repos:
- id: fix-byte-order-marker
- id: trailing-whitespace
- repo: https://github.com/psf/black-pre-commit-mirror
- rev: 24.4.2
+ rev: 24.8.0
hooks:
- id: black
- repo: https://github.com/PyCQA/isort
@@ -34,7 +34,7 @@ repos:
hooks:
- id: isort
- repo: https://github.com/pre-commit/mirrors-mypy
- rev: v1.11.1
+ rev: v1.11.2
hooks:
- id: mypy
args: [--follow-imports=silent]
diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py
index fb8e4605..7fbbf4ec 100644
--- a/capellambse_context_diagrams/__init__.py
+++ b/capellambse_context_diagrams/__init__.py
@@ -25,7 +25,7 @@
import capellambse.model as m
from capellambse.diagram import COLORS, CSSdef, capstyle
-from capellambse.metamodel import fa, information, la, oa, pa, sa
+from capellambse.metamodel import cs, fa, information, la, oa, pa, sa
from capellambse.model import DiagramType
from . import _elkjs, context, styling
@@ -193,6 +193,17 @@ def register_interface_context() -> None:
{"include_interface": True},
),
)
+ m.set_accessor(
+ cs.PhysicalLink,
+ ATTR_NAME,
+ context.InterfaceContextAccessor(
+ {
+ pa.PhysicalComponentPkg: DiagramType.PAB.value,
+ pa.PhysicalComponent: DiagramType.PAB.value,
+ },
+ {"include_interface": True, "display_port_labels": True},
+ ),
+ )
def register_functional_context() -> None:
diff --git a/capellambse_context_diagrams/_elkjs.py b/capellambse_context_diagrams/_elkjs.py
index d4b930dd..d6bceaf5 100644
--- a/capellambse_context_diagrams/_elkjs.py
+++ b/capellambse_context_diagrams/_elkjs.py
@@ -10,6 +10,7 @@
import collections.abc as cabc
import copy
+import enum
import json
import logging
import os
@@ -94,6 +95,34 @@
"""Options for increasing the edge straightness priority."""
+class PORT_LABEL_POSITION(enum.Enum):
+ """Position of port labels.
+
+ Attributes
+ ----------
+ OUTSIDE
+ The label is placed outside the port.
+ INSIDE
+ The label is placed inside the port owner box.
+ NEXT_TO_PORT_IF_POSSIBLE
+ The label is placed next to the port if space allows.
+ ALWAYS_SAME_SIDE
+ The label is always placed on the same side of the port.
+ ALWAYS_OTHER_SAME_SIDE
+ The label is always placed on the opposite side, but on the same
+ axis.
+ SPACE_EFFICIENT
+ The label is positioned in the most space-efficient location.
+ """
+
+ OUTSIDE = enum.auto()
+ INSIDE = enum.auto()
+ NEXT_TO_PORT_IF_POSSIBLE = enum.auto()
+ ALWAYS_SAME_SIDE = enum.auto()
+ ALWAYS_OTHER_SAME_SIDE = enum.auto()
+ SPACE_EFFICIENT = enum.auto()
+
+
class BaseELKModel(pydantic.BaseModel):
"""Base class for ELK models."""
diff --git a/capellambse_context_diagrams/collectors/exchanges.py b/capellambse_context_diagrams/collectors/exchanges.py
index f08f3733..ee18ceeb 100644
--- a/capellambse_context_diagrams/collectors/exchanges.py
+++ b/capellambse_context_diagrams/collectors/exchanges.py
@@ -13,7 +13,7 @@
from capellambse.metamodel import cs, fa
from capellambse.model import DiagramType as DT
-from .. import _elkjs, context
+from .. import _elkjs, context, errors
from . import generic, makers
logger = logging.getLogger(__name__)
@@ -307,6 +307,104 @@ def collect(self) -> None:
pass
+class PhysicalLinkContextCollector(ExchangeCollector):
+ """Collect necessary
+ [`_elkjs.ELKInputData`][capellambse_context_diagrams._elkjs.ELKInputData]
+ for building the ``PhysicalLink`` context.
+ """
+
+ left: _elkjs.ELKInputChild | None
+ """Left partner of the interface."""
+ right: _elkjs.ELKInputChild | None
+ """Right partner of the interface."""
+
+ def __init__(
+ self,
+ diagram: context.InterfaceContextDiagram,
+ data: _elkjs.ELKInputData,
+ params: dict[str, t.Any],
+ ) -> None:
+ self.left: _elkjs.ELKInputChild | None = None
+ self.right: _elkjs.ELKInputChild | None = None
+
+ super().__init__(diagram, data, params)
+
+ def get_owner_savely(self, attr_getter: t.Callable) -> m.ModelElement:
+ try:
+ owner = attr_getter(self.obj)
+ return owner
+ except RuntimeError:
+ # pylint: disable-next=raise-missing-from
+ raise errors.CapellambseError(
+ f"Failed to collect source of '{self.obj.name}'"
+ )
+ except AttributeError:
+ assert owner is None
+ # pylint: disable-next=raise-missing-from
+ raise errors.CapellambseError(
+ f"Port has no owner: '{self.obj.name}'"
+ )
+
+ def get_left_and_right(self) -> None:
+ source = self.get_owner_savely(self.get_source)
+ target = self.get_owner_savely(self.get_target)
+ self.left = makers.make_box(source, no_symbol=True)
+ self.right = makers.make_box(target, no_symbol=True)
+ self.data.children.extend([self.left, self.right])
+
+ def add_interface(self) -> None:
+ """Add the ComponentExchange (interface) to the collected data."""
+ ex_data = generic.ExchangeData(
+ self.obj,
+ self.data,
+ self.diagram.filters,
+ self.params,
+ is_hierarchical=False,
+ )
+ src, tgt = generic.exchange_data_collector(ex_data)
+ self.data.edges[-1].layoutOptions = copy.deepcopy(
+ _elkjs.EDGE_STRAIGHTENING_LAYOUT_OPTIONS
+ )
+ assert self.right is not None
+ assert self.left is not None
+ left_port, right_port = self.get_source_and_target_ports(src, tgt)
+ self.left.ports.append(left_port)
+ self.right.ports.append(right_port)
+
+ def get_source_and_target_ports(
+ self, src: m.ModelElement, tgt: m.ModelElement
+ ) -> tuple[_elkjs.ELKInputPort, _elkjs.ELKInputPort]:
+ """Return the source and target ports of the interface."""
+ left_port = makers.make_port(src.uuid)
+ right_port = makers.make_port(tgt.uuid)
+ if self.diagram._display_port_labels:
+ left_port.labels = makers.make_label(src.name)
+ right_port.labels = makers.make_label(tgt.name)
+
+ _plp = self.diagram._port_label_position
+ if not (plp := getattr(_elkjs.PORT_LABEL_POSITION, _plp, None)):
+ raise ValueError(f"Invalid port label position '{_plp}'.")
+
+ assert isinstance(plp, _elkjs.PORT_LABEL_POSITION)
+ port_label_position = plp.name
+
+ assert self.left is not None
+ self.left.layoutOptions["portLabels.placement"] = (
+ port_label_position
+ )
+ assert self.right is not None
+ self.right.layoutOptions["portLabels.placement"] = (
+ port_label_position
+ )
+ return left_port, right_port
+
+ def collect(self) -> None:
+ """Collect all allocated `PhysicalLink`s in the context."""
+ self.get_left_and_right()
+ if self.diagram._include_interface:
+ self.add_interface()
+
+
class FunctionalContextCollector(ExchangeCollector):
def __init__(
self,
diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py
index 5cbe959a..9e1055ab 100644
--- a/capellambse_context_diagrams/context.py
+++ b/capellambse_context_diagrams/context.py
@@ -15,6 +15,7 @@
from capellambse import diagram as cdiagram
from capellambse import helpers
from capellambse import model as m
+from capellambse.metamodel import cs
from . import _elkjs, filters, serializers, styling
from .collectors import (
@@ -222,6 +223,8 @@ class ContextDiagram(m.AbstractDiagram):
just the icon and the label. This is False if hierarchy was
identified.
* display_port_labels — Display port labels on the diagram.
+ * port_label_position - Position of the port labels. See
+ [`PORT_LABEL_POSITION`][capellambse_context_diagrams.context._elkjs.PORT_LABEL_POSITION].
"""
_display_symbols_as_boxes: bool
@@ -229,6 +232,7 @@ class ContextDiagram(m.AbstractDiagram):
_display_derived_interfaces: bool
_slim_center_box: bool
_display_port_labels: bool
+ _port_label_position: str
def __init__(
self,
@@ -251,6 +255,7 @@ def __init__(
"display_derived_interfaces": False,
"slim_center_box": True,
"display_port_labels": False,
+ "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name,
} | default_render_parameters
if standard_filter := STANDARD_FILTERS.get(class_):
@@ -311,11 +316,15 @@ def __len__(self) -> int:
def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram:
params = self._default_render_parameters | params
- transparent_background = params.pop("transparent_background", False)
+ transparent_background: bool = params.pop( # type: ignore[assignment]
+ "transparent_background", False
+ )
for param_name in self._default_render_parameters:
setattr(self, f"_{param_name}", params.pop(param_name))
- data = params.get("elkdata") or get_elkdata(self, params)
+ data: _elkjs.ELKInputData = params.get("elkdata") or get_elkdata(
+ self, params
+ ) # type: ignore[assignment]
if not isinstance(
self, (ClassTreeDiagram, InterfaceContextDiagram)
) and has_single_child(data):
@@ -323,7 +332,8 @@ def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram:
data = get_elkdata(self, params)
layout = try_to_layout(data)
- add_context(layout, params.get("is_legend", False))
+ is_legend: bool = params.get("is_legend", False) # type: ignore[assignment]
+ add_context(layout, is_legend)
return self.serializer.make_diagram(
layout,
transparent_background=transparent_background,
@@ -358,6 +368,9 @@ class InterfaceContextDiagram(ContextDiagram):
context diagram target: The interface ComponentExchange.
* hide_functions — Boolean flag to enable white box view: Only
displaying Components or Entities.
+ * display_port_labels — Display port labels on the diagram.
+ * port_label_position — Position of the port labels. See
+ [`PORT_LABEL_POSITION`][capellambse_context_diagrams.context._elkjs.PORT_LABEL_POSITION].
In addition to all other render parameters of
[`ContextDiagram`][capellambse_context_diagrams.context.ContextDiagram].
@@ -365,6 +378,8 @@ class InterfaceContextDiagram(ContextDiagram):
_include_interface: bool
_hide_functions: bool
+ _display_port_labels: bool
+ _port_label_position: str
def __init__(
self,
@@ -378,6 +393,8 @@ def __init__(
"include_interface": False,
"hide_functions": False,
"display_symbols_as_boxes": True,
+ "display_port_labels": False,
+ "port_label_position": _elkjs.PORT_LABEL_POSITION.OUTSIDE.name,
} | default_render_parameters
super().__init__(
class_,
@@ -396,8 +413,14 @@ def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram:
for param_name in self._default_render_parameters:
setattr(self, f"_{param_name}", params.pop(param_name))
+ collector: t.Type[exchanges.ExchangeCollector]
+ if isinstance(self.target, cs.PhysicalLink):
+ collector = exchanges.PhysicalLinkContextCollector
+ else:
+ collector = exchanges.InterfaceContextCollector
+
super_params["elkdata"] = exchanges.get_elkdata_for_exchanges(
- self, exchanges.InterfaceContextCollector, params
+ self, collector, params
)
return super()._create_diagram(super_params)
diff --git a/capellambse_context_diagrams/errors.py b/capellambse_context_diagrams/errors.py
new file mode 100644
index 00000000..c557c5b6
--- /dev/null
+++ b/capellambse_context_diagrams/errors.py
@@ -0,0 +1,8 @@
+# SPDX-FileCopyrightText: 2022 Copyright DB InfraGO AG and the capellambse-context-diagrams contributors
+# SPDX-License-Identifier: Apache-2.0
+
+"""Errors for capellambse_context_diagrams."""
+
+
+class CapellambseError(Exception):
+ """Error raised by capellambse."""
diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py
index b4a78a27..7447b930 100644
--- a/capellambse_context_diagrams/serializers.py
+++ b/capellambse_context_diagrams/serializers.py
@@ -64,9 +64,7 @@ def __init__(self, elk_diagram: context.ContextDiagram) -> None:
self._junctions: dict[str, EdgeContext] = {}
def make_diagram(
- self,
- data: _elkjs.ELKOutputData,
- **kwargs: dict[str, t.Any],
+ self, data: _elkjs.ELKOutputData, **kwargs: t.Any
) -> cdiagram.Diagram:
"""Transform a layouted diagram into a `diagram.Diagram`.
@@ -225,6 +223,13 @@ class type that stores all previously named classes.
else:
attr_name = "labels"
+ if (
+ parent.port
+ and self._diagram._port_label_position
+ == _elkjs.PORT_LABEL_POSITION.OUTSIDE.name
+ ):
+ bring_labels_closer_to_port(child)
+
if labels := getattr(parent, attr_name):
label_box = labels[-1]
label_box.label += " " + child.text
@@ -396,6 +401,15 @@ def reverse_edge_refpoints(child: _elkjs.ELKOutputEdge) -> None:
child.routingPoints = child.routingPoints[::-1]
+def bring_labels_closer_to_port(child: _elkjs.ELKOutputLabel) -> None:
+ """Move labels closer to the port."""
+ if child.position.x > 1:
+ child.position.x = -5
+
+ if child.position.x < -11:
+ child.position.x += 18
+
+
EDGE_HANDLER: dict[str | None, cabc.Callable[[_elkjs.ELKOutputEdge], None]] = {
"Generalization": reverse_edge_refpoints
}
diff --git a/docs/gen_images.py b/docs/gen_images.py
index 0f13f1dd..4d7f6643 100644
--- a/docs/gen_images.py
+++ b/docs/gen_images.py
@@ -34,6 +34,7 @@
interface_context_diagram_uuids: dict[str, str] = {
"Left to right": "3ef23099-ce9a-4f7d-812f-935f47e7938d",
"Interface": "2f8ed849-fbda-4902-82ec-cbf8104ae686",
+ "Cable 1": "da949a89-23c0-4487-88e1-f14b33326570",
}
hierarchy_context = "16b4fcc5-548d-4721-b62a-d3d5b1c1d2eb"
diagram_uuids = general_context_diagram_uuids | interface_context_diagram_uuids
diff --git a/docs/interface.md b/docs/interface.md
index 280238fb..b98f9921 100644
--- a/docs/interface.md
+++ b/docs/interface.md
@@ -8,20 +8,41 @@
The data is collected by [get_elkdata_for_exchanges][capellambse_context_diagrams.collectors.exchanges.get_elkdata_for_exchanges] which is using the [`InterfaceContextCollector`][capellambse_context_diagrams.collectors.exchanges.InterfaceContextCollector] underneath.
You can render an interface context view just with `context_diagram` on any
-[`fa.ComponentExchange`][capellambse.metamodel.fa.ComponentExchange]:
+of the following Model elements:
-``` py
-import capellambse
+- ??? example "[`fa.ComponentExchange`][capellambse.metamodel.fa.ComponentExchange]:"
-model = capellambse.MelodyModel("tests/data/ContextDiagram.aird")
-diag = model.by_uuid("3ef23099-ce9a-4f7d-812f-935f47e7938d").context_diagram
-diag.render("svgdiagram").save(pretty=True)
-```
+ ``` py
+ import capellambse
-
+ model = capellambse.MelodyModel("tests/data/ContextDiagram.aird")
+ diag = model.by_uuid("3ef23099-ce9a-4f7d-812f-935f47e7938d").context_diagram
+ diag.render("svgdiagram").save(pretty=True)
+ ```
+
+
+
+- ??? example "[`cs.PhysicalLink`][capellambse.metamodel.cs.PhysicalLink]:"
+
+ Per default port labels are enabled with port label position set to
+ ``OUTSIDE``. See [`PORT_LABEL_POSITION`][capellambse_context_diagrams._elkjs.PORT_LABEL_POSITION] for more information about the available options
+ for port label positioning.
+
+ ``` py
+ import capellambse
+
+ model = capellambse.MelodyModel("tests/data/ContextDiagram.aird")
+ diag = model.by_uuid("da949a89-23c0-4487-88e1-f14b33326570").context_diagram
+ diag.render("svgdiagram").save(pretty=True)
+ ```
+
+
## Exclude the interface itself in the context
??? example "Exclude the interface in the Interface Context"
diff --git a/tests/data/ContextDiagram.aird b/tests/data/ContextDiagram.aird
index e0515cdb..c32b098d 100644
--- a/tests/data/ContextDiagram.aird
+++ b/tests/data/ContextDiagram.aird
@@ -15,7 +15,7 @@
-
+
@@ -13153,6 +13153,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -13202,6 +13262,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -13211,7 +13319,7 @@
-
+
@@ -13220,7 +13328,7 @@
-
+
@@ -13252,7 +13360,7 @@
-
+
@@ -13354,6 +13462,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ KEEP_LOCATION
+ KEEP_SIZE
+ KEEP_RATIO
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ KEEP_LOCATION
+ KEEP_SIZE
+ KEEP_RATIO
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ KEEP_LOCATION
+ KEEP_SIZE
+ KEEP_RATIO
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/data/ContextDiagram.capella b/tests/data/ContextDiagram.capella
index fe7ed768..6a32b948 100644
--- a/tests/data/ContextDiagram.capella
+++ b/tests/data/ContextDiagram.capella
@@ -4783,6 +4783,12 @@ The predator is far away
name="Node Software" abstractType="#2de65884-8c89-4c55-8941-4aa1a07b8d86"/>
+
+
+
@@ -4812,6 +4818,12 @@ The predator is far away
+
+
+
+ id="682edffb-6b44-48c0-826b-32eb217eb81c" name="Cable 1 Target"/>
@@ -5191,6 +5203,21 @@ The predator is far away
id="3438195c-9a99-4328-8c56-6f6a2cb37cb7" targetElement="#02a8c8c8-5d86-4559-81da-76c5d37d5e74"
sourceElement="#643069e4-aad0-4477-832d-d11913d87730"/>
+
+
+
+
+
+
+
+
+
id="fdb34c92-7c49-491d-bf11-dd139930786e" name="Physical Component"
nature="NODE">
+ id="f3722f0a-c7de-421d-b4aa-fea6f5278672" name="Cable 1 Source"/>
diff --git a/tests/test_interface_diagrams.py b/tests/test_interface_diagrams.py
index f6fd69d0..fb839dbd 100644
--- a/tests/test_interface_diagrams.py
+++ b/tests/test_interface_diagrams.py
@@ -5,14 +5,17 @@
import pytest
TEST_INTERFACE_UUID = "2f8ed849-fbda-4902-82ec-cbf8104ae686"
+TEST_PA_INTERFACE_UUID = "25f46b82-1bb8-495a-b6bc-3ad086aad02e"
+TEST_CABLE_UUID = "da949a89-23c0-4487-88e1-f14b33326570"
@pytest.mark.parametrize(
"uuid",
[
- # pytest.param("3c9764aa-4981-44ef-8463-87a053016635", id="OA"),
pytest.param("86a1afc2-b7fd-4023-bbd5-ab44f5dc2c28", id="SA"),
pytest.param("3ef23099-ce9a-4f7d-812f-935f47e7938d", id="LA"),
+ pytest.param(TEST_PA_INTERFACE_UUID, id="PA - ComponentExchange"),
+ pytest.param(TEST_CABLE_UUID, id="PA - PhysicalLink"),
],
)
def test_interface_diagrams_get_rendered(
@@ -77,3 +80,27 @@ def test_interface_diagram_with_nested_functions(
diag = obj.context_diagram.render(None)
assert {b.uuid for b in diag[fnc.uuid].children} >= expected_uuids
+
+
+@pytest.mark.parametrize(
+ "port_label_position",
+ [
+ "OUTSIDE",
+ "INSIDE",
+ "NEXT_TO_PORT_IF_POSSIBLE",
+ "ALWAYS_SAME_SIDE",
+ "ALWAYS_OTHER_SAME_SIDE",
+ "SPACE_EFFICIENT",
+ ],
+)
+def test_interface_diagram_with_label_positions(
+ model: capellambse.MelodyModel, port_label_position: str
+) -> None:
+ obj = model.by_uuid(TEST_CABLE_UUID)
+
+ diag = obj.context_diagram.render(
+ None, port_label_position=port_label_position
+ )
+
+ assert diag["f3722f0a-c7de-421d-b4aa-fea6f5278672"]
+ assert diag["682edffb-6b44-48c0-826b-32eb217eb81c"]