Skip to content

Commit

Permalink
feat: PhysicalLink interface context diagram
Browse files Browse the repository at this point in the history
Merge pull request #138 from DSD-DBS/feat-physical-link-context
  • Loading branch information
ewuerger authored Sep 18, 2024
2 parents 0c8daab + 6e58c40 commit 64d7be8
Show file tree
Hide file tree
Showing 12 changed files with 492 additions and 29 deletions.
4 changes: 2 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,15 @@ 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
rev: 5.13.2
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]
Expand Down
13 changes: 12 additions & 1 deletion capellambse_context_diagrams/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions capellambse_context_diagrams/_elkjs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import collections.abc as cabc
import copy
import enum
import json
import logging
import os
Expand Down Expand Up @@ -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."""

Expand Down
100 changes: 99 additions & 1 deletion capellambse_context_diagrams/collectors/exchanges.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 27 additions & 4 deletions capellambse_context_diagrams/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -222,13 +223,16 @@ 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
_display_parent_relation: bool
_display_derived_interfaces: bool
_slim_center_box: bool
_display_port_labels: bool
_port_label_position: str

def __init__(
self,
Expand All @@ -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_):
Expand Down Expand Up @@ -311,19 +316,24 @@ 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):
self._display_derived_interfaces = True
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,
Expand Down Expand Up @@ -358,13 +368,18 @@ 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].
"""

_include_interface: bool
_hide_functions: bool
_display_port_labels: bool
_port_label_position: str

def __init__(
self,
Expand All @@ -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_,
Expand All @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions capellambse_context_diagrams/errors.py
Original file line number Diff line number Diff line change
@@ -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."""
20 changes: 17 additions & 3 deletions capellambse_context_diagrams/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
1 change: 1 addition & 0 deletions docs/gen_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 64d7be8

Please sign in to comment.