diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7ed76d1b..07075831 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -47,7 +47,7 @@ repos:
- --license-filepath
- license_header.txt
- --comment-style
- - '#'
+ - "#"
- id: insert-license
name: Insert Licence for HTML/XML/SVG files
files: '\.html$|\.md$|\.svg$'
@@ -56,7 +56,7 @@ repos:
- --license-filepath
- license_header.txt
- --comment-style
- - ''
+ - ""
- id: insert-license
name: Insert Licence for CSS files
files: '\.css$'
@@ -65,7 +65,7 @@ repos:
- --license-filepath
- license_header.txt
- --comment-style
- - '/*| *| */'
+ - "/*| *| */"
- repo: https://github.com/fsfe/reuse-tool
rev: v2.1.0
hooks:
diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py
index 130af425..4a559eca 100644
--- a/capellambse_context_diagrams/__init__.py
+++ b/capellambse_context_diagrams/__init__.py
@@ -48,6 +48,7 @@ def init() -> None:
register_classes()
register_interface_context()
register_tree_view()
+ register_realization_view()
# register_functional_context() XXX: Future
@@ -152,9 +153,33 @@ def register_functional_context() -> None:
def register_tree_view() -> None:
- """Add the `tree_view` attribute to ``Class``es."""
+ """Add the ``tree_view`` attribute to ``Class``es."""
common.set_accessor(
information.Class,
"tree_view",
context.ClassTreeAccessor(DiagramType.CDB.value),
)
+
+
+def register_realization_view() -> None:
+ """Add the ``realization_view`` attribute to various objects.
+
+ Adds ``realization_view`` to Activities, Functions and Components
+ of all layers.
+ """
+ supported_classes: list[ClassPair] = [
+ (oa.Entity, DiagramType.OAB),
+ (oa.OperationalActivity, DiagramType.OAIB),
+ (ctx.SystemComponent, DiagramType.SAB),
+ (ctx.SystemFunction, DiagramType.SDFB),
+ (la.LogicalComponent, DiagramType.LAB),
+ (la.LogicalFunction, DiagramType.LDFB),
+ (pa.PhysicalComponent, DiagramType.PAB),
+ (pa.PhysicalFunction, DiagramType.PDFB),
+ ]
+ for class_, dgcls in supported_classes:
+ common.set_accessor(
+ class_,
+ "realization_view",
+ context.RealizationViewContextAccessor(dgcls.value),
+ )
diff --git a/capellambse_context_diagrams/_elkjs.py b/capellambse_context_diagrams/_elkjs.py
index 7ebe3995..a4574c97 100644
--- a/capellambse_context_diagrams/_elkjs.py
+++ b/capellambse_context_diagrams/_elkjs.py
@@ -43,7 +43,8 @@
NODE_HOME = Path(capellambse.dirs.user_cache_dir, "elkjs", "node_modules")
PATH_TO_ELK_JS = Path(__file__).parent / "elk.js"
REQUIRED_NPM_PKG_VERSIONS: t.Dict[str, str] = {
- "elkjs": "0.8.2",
+ # "elkjs": "0.8.2",
+ "elkjs": "file:~/elk-master/git/elkjs",
}
"""npm package names and versions required by this Python module."""
@@ -149,18 +150,24 @@ class ELKSize(t.TypedDict):
height: t.Union[int, float]
-class ELKOutputData(t.TypedDict):
- """Data that comes from ELK."""
+class ELKOutputElement(t.TypedDict):
+ """Base class for all elements that comes out of ELK."""
id: str
+
+ style: dict[str, t.Any]
+
+
+class ELKOutputData(ELKOutputElement):
+ """Data that comes from ELK."""
+
type: t.Literal["graph"]
children: cabc.MutableSequence[ELKOutputChild] # type: ignore
-class ELKOutputNode(t.TypedDict):
+class ELKOutputNode(ELKOutputElement):
"""Node that comes out of ELK."""
- id: str
type: t.Literal["node"]
children: cabc.MutableSequence[ELKOutputChild] # type: ignore
@@ -168,20 +175,18 @@ class ELKOutputNode(t.TypedDict):
size: ELKSize
-class ELKOutputJunction(t.TypedDict):
+class ELKOutputJunction(ELKOutputElement):
"""Exchange-Junction that comes out of ELK."""
- id: str
type: t.Literal["junction"]
position: ELKPoint
size: ELKSize
-class ELKOutputPort(t.TypedDict):
+class ELKOutputPort(ELKOutputElement):
"""Port that comes out of ELK."""
- id: str
type: t.Literal["port"]
children: cabc.MutableSequence[ELKOutputLabel]
@@ -189,10 +194,9 @@ class ELKOutputPort(t.TypedDict):
size: ELKSize
-class ELKOutputLabel(t.TypedDict):
+class ELKOutputLabel(ELKOutputElement):
"""Label that comes out of ELK."""
- id: str
type: t.Literal["label"]
text: str
@@ -200,11 +204,11 @@ class ELKOutputLabel(t.TypedDict):
size: ELKSize
-class ELKOutputEdge(t.TypedDict):
+class ELKOutputEdge(ELKOutputElement):
"""Edge that comes out of ELK."""
- id: str
type: t.Literal["edge"]
+
sourceId: str
targetId: str
routingPoints: cabc.MutableSequence[ELKPoint]
diff --git a/capellambse_context_diagrams/collectors/makers.py b/capellambse_context_diagrams/collectors/makers.py
index 469ae5b5..3458cd42 100644
--- a/capellambse_context_diagrams/collectors/makers.py
+++ b/capellambse_context_diagrams/collectors/makers.py
@@ -7,7 +7,7 @@
import typing_extensions as te
from capellambse import helpers
-from capellambse.model import common, layers
+from capellambse.model import common, crosslayer, layers
from capellambse.svg.decorations import icon_padding, icon_size
from .. import _elkjs, context
diff --git a/capellambse_context_diagrams/collectors/realization_view.py b/capellambse_context_diagrams/collectors/realization_view.py
new file mode 100644
index 00000000..9ff4859d
--- /dev/null
+++ b/capellambse_context_diagrams/collectors/realization_view.py
@@ -0,0 +1,188 @@
+# SPDX-FileCopyrightText: 2022 Copyright DB Netz AG and the capellambse-context-diagrams contributors
+# SPDX-License-Identifier: Apache-2.0
+"""This submodule defines the collector for the RealizationView diagram."""
+from __future__ import annotations
+
+import collections.abc as cabc
+import copy
+import re
+import typing as t
+
+from capellambse.model import common, crosslayer
+from capellambse.model.crosslayer import cs, fa
+
+from .. import _elkjs, context
+from . import makers
+
+RE_LAYER_PTRN = re.compile(r"([A-Z]?[a-z]+)")
+
+
+def collector(
+ diagram: context.ContextDiagram, params: dict[str, t.Any]
+) -> tuple[_elkjs.ELKInputData, list[_elkjs.ELKInputEdge]]:
+ """Return the class tree data for ELK."""
+ data = makers.make_diagram(diagram)
+ layout_options: _elkjs.LayoutOptions = copy.deepcopy(
+ _elkjs.RECT_PACKING_LAYOUT_OPTIONS # type:ignore[arg-type]
+ )
+ layout_options["elk.contentAlignment"] = "V_CENTER H_CENTER"
+ del layout_options["widthApproximation.targetWidth"]
+ data["layoutOptions"] = layout_options
+ _collector = COLLECTORS[params.get("search_direction", "ALL")]
+ lay_to_els = _collector(diagram.target, params.get("depth", 1))
+ layer_layout_options: _elkjs.LayoutOptions = layout_options | { # type: ignore[operator]
+ "nodeSize.constraints": "[NODE_LABELS,MINIMUM_SIZE]",
+ } # type: ignore[assignment]
+ edges: list[_elkjs.ELKInputEdge] = []
+ for layer, elements in lay_to_els.items():
+ labels = [makers.make_label(layer)]
+ width, height = makers.calculate_height_and_width(labels)
+ layer_box: _elkjs.ELKInputChild = {
+ "id": elements[0]["layer"].uuid,
+ "children": [],
+ "height": width,
+ "width": height,
+ "layoutOptions": layer_layout_options,
+ }
+ children: dict[str, _elkjs.ELKInputChild] = {}
+ for elt in elements:
+ if elt["origin"] is not None:
+ edges.append(
+ {
+ "id": f'{elt["origin"].uuid}_{elt["element"].uuid}',
+ "sources": [elt["origin"].uuid],
+ "targets": [elt["element"].uuid],
+ }
+ )
+
+ if not (element_box := children.get(elt["element"].uuid)):
+ element_box = makers.make_box(elt["element"], no_symbol=True)
+ children[elt["element"].uuid] = element_box
+ layer_box["children"].append(element_box)
+ index = len(layer_box["children"]) - 1
+
+ if params.get("show_owners"):
+ owner = elt["element"].owner
+ if not isinstance(owner, (fa.Function, cs.Component)):
+ continue
+
+ if not (owner_box := children.get(owner.uuid)):
+ owner_box = makers.make_box(owner, no_symbol=True)
+ owner_box["height"] += element_box["height"]
+ children[owner.uuid] = owner_box
+ layer_box["children"].append(owner_box)
+
+ del layer_box["children"][index]
+ owner_box.setdefault("children", []).append(element_box)
+ owner_box["width"] += element_box["width"]
+ if (
+ elt["origin"] is not None
+ and elt["origin"].owner.uuid in children
+ and owner.uuid in children
+ ):
+ eid = f'{elt["origin"].owner.uuid}_{owner.uuid}'
+ edges.append(
+ {
+ "id": eid,
+ "sources": [elt["origin"].owner.uuid],
+ "targets": [owner.uuid],
+ }
+ )
+
+ data["children"].append(layer_box)
+ data["children"] = data["children"][::-1]
+ return data, edges
+
+
+def collect_realized(
+ start: common.GenericElement, depth: int
+) -> dict[LayerLiteral, list[dict[str, t.Any]]]:
+ """Collect all elements from ``realized_`` attributes up to depth."""
+ return collect_elements(start, depth, "ABOVE", "realized")
+
+
+def collect_realizing(
+ start: common.GenericElement, depth: int
+) -> dict[LayerLiteral, list[dict[str, t.Any]]]:
+ """Collect all elements from ``realizing_`` attributes down to depth."""
+ return collect_elements(start, depth, "BELOW", "realizing")
+
+
+def collect_all(
+ start: common.GenericElement, depth: int
+) -> dict[LayerLiteral, list[common.GenericElement]]:
+ """Collect all elements in both ABOVE and BELOW directions."""
+ above = collect_realized(start, depth)
+ below = collect_realizing(start, depth)
+ return above | below
+
+
+def collect_elements(
+ start: common.GenericElement,
+ depth: int,
+ direction: str,
+ attribute_prefix: str,
+ origin: common.GenericElement = None,
+) -> dict[LayerLiteral, list[dict[str, t.Any]]]:
+ """Collect elements based on the specified direction and attribute name."""
+ layer_obj, layer = find_layer(start)
+ collected_elements: dict[LayerLiteral, list[dict[str, t.Any]]] = {
+ layer: [{"element": start, "origin": origin, "layer": layer_obj}]
+ }
+ if (
+ (direction == "ABOVE" and layer == "Operational")
+ or (direction == "BELOW" and layer == "Physical")
+ or depth == 0
+ ):
+ return collected_elements
+
+ if isinstance(start, fa.Function):
+ attribute_name = f"{attribute_prefix}_functions"
+ else:
+ assert isinstance(start, cs.Component)
+ attribute_name = f"{attribute_prefix}_components"
+
+ for element in getattr(start, attribute_name, []):
+ sub_collected = collect_elements(
+ element, depth - 1, direction, attribute_prefix, origin=start
+ )
+ for sub_layer, sub_elements in sub_collected.items():
+ collected_elements.setdefault(sub_layer, []).extend(sub_elements)
+ return collected_elements
+
+
+LayerLiteral = t.Union[
+ t.Literal["Operational"],
+ t.Literal["System"],
+ t.Literal["Logical"],
+ t.Literal["Physical"],
+]
+
+
+def find_layer(
+ obj: common.GenericElement,
+) -> tuple[crosslayer.BaseArchitectureLayer, LayerLiteral]:
+ """Return the layer object and its literal.
+
+ Return either one of the following:
+ * ``Operational``
+ * ``System``
+ * ``Logical``
+ * ``Physical``
+ """
+ parent = obj
+ while not isinstance(parent, crosslayer.BaseArchitectureLayer):
+ parent = parent.parent
+ if not (match := RE_LAYER_PTRN.match(type(parent).__name__)):
+ raise ValueError("No layer was found.")
+ return parent, match.group(1) # type:ignore[return-value]
+
+
+Collector = cabc.Callable[
+ [common.GenericElement, int], dict[LayerLiteral, list[dict[str, t.Any]]]
+]
+COLLECTORS: dict[str, Collector] = {
+ "ALL": collect_all,
+ "ABOVE": collect_realized,
+ "BELOW": collect_realizing,
+}
diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py
index 828c3668..e5ef3a09 100644
--- a/capellambse_context_diagrams/context.py
+++ b/capellambse_context_diagrams/context.py
@@ -10,13 +10,15 @@
import copy
import json
import logging
+import math
import typing as t
from capellambse import diagram as cdiagram
+from capellambse import helpers
from capellambse.model import common, diagram, modeltypes
from . import _elkjs, filters, serializers, styling
-from .collectors import exchanges, get_elkdata, tree_view
+from .collectors import exchanges, get_elkdata, realization_view, tree_view
logger = logging.getLogger(__name__)
@@ -142,7 +144,7 @@ def __get__( # type: ignore
obj: common.T | None,
objtype: type | None = None,
) -> common.Accessor | ContextDiagram:
- """Make a ContextDiagram for the given model object."""
+ """Make a ClassTreeDiagram for the given model object."""
del objtype
if obj is None: # pragma: no cover
return self
@@ -150,6 +152,26 @@ def __get__( # type: ignore
return self._get(obj, ClassTreeDiagram, "{}_class_tree")
+class RealizationViewContextAccessor(ContextAccessor):
+ """Provides access to the realization view diagrams."""
+
+ # pylint: disable=super-init-not-called
+ def __init__(self, diagclass: str) -> None:
+ self._dgcls = diagclass
+
+ def __get__( # type: ignore
+ self,
+ obj: common.T | None,
+ objtype: type | None = None,
+ ) -> common.Accessor | ContextDiagram:
+ """Make a RealizationViewDiagram for the given model object."""
+ del objtype
+ if obj is None: # pragma: no cover
+ return self
+ assert isinstance(obj, common.GenericElement)
+ return self._get(obj, RealizationViewDiagram, "{}_realization_view")
+
+
class ContextDiagram(diagram.AbstractDiagram):
"""An automatically generated context diagram.
@@ -275,12 +297,8 @@ def render(self, fmt: str | None, /, **params) -> t.Any:
return super().render(fmt, **rparams)
def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram:
- try:
- data = params.get("elkdata") or get_elkdata(self, params)
- layout = _elkjs.call_elkjs(data)
- except json.JSONDecodeError as error:
- logger.error(json.dumps(data, indent=4))
- raise error
+ data = params.get("elkdata") or get_elkdata(self, params)
+ layout = self._try_to_layout(data)
return self.serializer.make_diagram(layout)
@property # type: ignore
@@ -292,6 +310,15 @@ def filters(self, value: cabc.Iterable[str]) -> None:
self.__filters.clear()
self.__filters |= set(value)
+ def _try_to_layout(
+ self, data: _elkjs.ELKInputData
+ ) -> _elkjs.ELKOutputData:
+ try:
+ return _elkjs.call_elkjs(data)
+ except json.JSONDecodeError as error:
+ logger.error(json.dumps(data, indent=4))
+ raise error
+
class InterfaceContextDiagram(ContextDiagram):
"""An automatically generated Context Diagram exclusively for
@@ -379,6 +406,202 @@ def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram:
return class_diagram
+class RealizationViewDiagram(ContextDiagram):
+ """An automatically generated RealizationViewDiagram Diagram.
+
+ This diagram is exclusively for ``Activity``, ``Function``s,
+ ``Entity`` and ``Components`` of all layers.
+ """
+
+ def __init__(self, class_: str, obj: common.GenericElement, **kw) -> None:
+ super().__init__(class_, obj, **kw, display_symbols_as_boxes=True)
+
+ @property
+ def uuid(self) -> str: # type: ignore
+ """Returns the UUID of the diagram."""
+ return f"{self.target.uuid}_realization_view"
+
+ @property
+ def name(self) -> str: # type: ignore
+ """Returns the name of the diagram."""
+ return f"Reailization view of {self.target.name}"
+
+ def _create_diagram(self, params: dict[str, t.Any]) -> cdiagram.Diagram:
+ params.setdefault("depth", params.get("depth", 1))
+ params.setdefault(
+ "search_direction", params.get("search_direction", "ALL")
+ )
+ params.setdefault("show_owners", params.get("show_owners", True))
+ data, edges = realization_view.collector(self, params)
+ layout = self._try_to_layout(data)
+ min_width = max(child["size"]["width"] for child in layout["children"]) # type: ignore[typeddict-item]
+ min_width += 15.0
+ for layer in data["children"]:
+ min_height: int | float = 0
+ for layout_layer in layout["children"]:
+ if layer["id"] != layout_layer["id"]:
+ continue
+ assert layout_layer["type"] != "edge"
+ min_height = layout_layer["size"]["height"]
+
+ assert min_height > 0
+ layer["width"] = min_width
+ layer["layoutOptions"][
+ "nodeSize.minimum"
+ ] = f"({min_width},{min_height})"
+
+ layout = self._try_to_layout(data)
+ self._add_layer_labels(layout)
+ return self.serializer.make_diagram(layout)
+
+ def _add_layer_labels(self, layout: _elkjs.ELKOutputData) -> None:
+ for layer in layout["children"]:
+ if layer["type"] != "node":
+ continue
+
+ layer_obj = self.serializer.model.by_uuid(layer["id"])
+ _, layer_name = realization_view.find_layer(layer_obj)
+ pos = layer["position"]["x"], layer["position"]["y"]
+ size = layer["size"]["width"], layer["size"]["height"]
+ width, height = helpers.get_text_extent(layer_name)
+ x, y, tspan_y = calculate_label_position(*pos, *size)
+ label_box: _elkjs.ELKOutputChild = {
+ "type": "label",
+ "id": "None",
+ "text": layer_name,
+ "position": {"x": x, "y": y},
+ "size": {"width": width, "height": height},
+ "style": {
+ "text_transform": f"rotate(-90, {x}, {y}) {tspan_y}",
+ "text_fill": "grey",
+ },
+ }
+ layer["children"].insert(0, label_box)
+ layer["style"] = {"stroke": "grey", "rx": 5, "ry": 5}
+
+ @t.no_type_check
+ def _fix_child_box_positions(
+ self, layout: _elkjs.ELKOutputData, padding: int = 3
+ ) -> None:
+ """Arrange boxes in rows and center them within their layer."""
+ for layer in layout["children"]:
+ layer_width = layer["size"]["width"]
+ layer_height = layer["size"]["height"]
+ arrange_and_center_children(
+ layer["children"], layer_width, layer_height, padding
+ )
+
+ @t.no_type_check
+ def _add_edges_to_layout(
+ self,
+ layout: _elkjs.ELKOutputData,
+ edges: cabc.Iterable[_elkjs.ELKInputEdge],
+ ) -> None:
+ """Calculates routing points for given ``edges``.
+
+ This can be removed when ELK's rectangle packing algorithm is
+ aware of edges, or a sole routing algorithm can be used.
+ """
+
+ def find_source_and_target(
+ edge: _elkjs.ELKInputEdge,
+ layout: _elkjs.ELKOutputData | _elkjs.ELKOutputNode,
+ source: _elkjs.ELKOutputNode | None = None,
+ target: _elkjs.ELKOutputNode | None = None,
+ ref: tuple[float, float] = (0, 0),
+ ) -> tuple[_elkjs.ELKOutputNode | None, _elkjs.ELKOutputNode | None]:
+ if layout["type"] == "node":
+ new_source, new_target = check_source_and_target(
+ layout, edge, source, target, ref
+ )
+ source = new_source if source is None else source
+ target = new_target if target is None else target
+
+ if not source or not target:
+ for child in layout.get("children", []):
+ if child["type"] != "node":
+ continue
+
+ cpos = child["position"]
+ child_refpoint = (ref[0] + cpos["x"], ref[1] + cpos["y"])
+ source, target = find_source_and_target(
+ edge, child, source, target, child_refpoint
+ )
+ if source and target:
+ break
+ return source, target
+
+ def check_source_and_target(
+ node: _elkjs.ELKOutputNode,
+ edge: _elkjs.ELKInputEdge,
+ source: _elkjs.ELKOutputNode | None = None,
+ target: _elkjs.ELKOutputNode | None = None,
+ refpoint: tuple[float, float] = (0, 0),
+ ) -> tuple[_elkjs.ELKOutputNode | None, _elkjs.ELKOutputNode | None]:
+ adjusted_position: _elkjs.ELKPoint = {
+ "x": node["position"]["x"] + refpoint[0],
+ "y": node["position"]["y"] + refpoint[1],
+ }
+ box = copy.deepcopy(node)
+ box["position"] = adjusted_position
+ if node["id"] in edge["sources"]:
+ return box, target
+ elif node["id"] in edge["targets"]:
+ return source, box
+ return source, target
+
+ def calculate_midpoints(
+ node: _elkjs.ELKOutputNode,
+ ) -> list[tuple[float, float]]:
+ """Return a list of midpoints of each side of the given node."""
+ position = node["position"]
+ size = node["size"]
+ x, y = position["x"], position["y"]
+ width, height = size["width"], size["height"]
+ return [
+ (x + width / 2, y), # Top midpoint
+ (x + width, y + height / 2), # Right midpoint
+ (x + width / 2, y + height), # Bottom midpoint
+ (x, y + height / 2), # Left midpoint
+ ]
+
+ for edge in edges:
+ source, target = find_source_and_target(edge, layout)
+ if source is None:
+ logger.error(
+ "The source box for edge %r can't be found.", edge["id"]
+ )
+ continue
+
+ if target is None:
+ logger.error(
+ "The target box for edge %r can't be found.", edge["id"]
+ )
+ continue
+
+ s_up, s_right, s_down, s_left = calculate_midpoints(source)
+ t_up, t_right, t_down, t_left = calculate_midpoints(target)
+
+ vectors: list[tuple[cdiagram.Vector2D]] = [
+ (cdiagram.Vector2D(*s_point), cdiagram.Vector2D(*t_point))
+ for t_point in [t_up, t_right, t_down, t_left]
+ for s_point in [s_up, s_right, s_down, s_left]
+ ]
+ start, end = min(vectors, key=lambda vec: (vec[0] - vec[1]).length)
+ layout["children"].append(
+ {
+ "id": edge["id"],
+ "type": "edge",
+ "sourceId": source["id"],
+ "targetId": target["id"],
+ "routingPoints": [
+ {"x": start.x, "y": start.y},
+ {"x": end.x, "y": end.y},
+ ],
+ }
+ )
+
+
def stack_diagrams(
first: cdiagram.Diagram,
second: cdiagram.Diagram,
@@ -391,3 +614,81 @@ def stack_diagrams(
new = copy.deepcopy(element)
new.move(offset)
first += new
+
+
+def calculate_label_position(
+ x: float,
+ y: float,
+ width: float,
+ height: float,
+ padding: float = 10,
+) -> tuple[float, float, float]:
+ """Calculate the position of the label and tspan.
+
+ The function calculates the center of the rectangle and uses the
+ rectangle's width and height to adjust its position within it. The
+ text is assumed to be horizontally and vertically centered within
+ the rectangle. The tspan y coordinate is for positioning the label
+ right under the left side of the rectangle.
+
+ Parameters
+ ----------
+
+ Returns
+ -------
+ tuple
+ A tuple containing the x- and y-coordinate for the text element
+ and the adjusted y-coordinate for the tspan element.
+ """
+ center_y = y + height / 2
+ tspan_y = center_y - width / 2 + padding
+ return (x + width / 2, center_y, tspan_y)
+
+
+def arrange_and_center_children(
+ children: cabc.MutableSequence[_elkjs.ELKOutputChild],
+ layer_width: int | float,
+ layer_height: int | float,
+ padding: int | float,
+) -> None:
+ y_position = padding
+ row_width = 0
+ row_height = 0
+ row_start_index = 0
+
+ for i, child in enumerate(children):
+ if child["type"] != "node":
+ continue
+
+ child_width = child["size"]["width"]
+ child_height = child["size"]["height"]
+
+ if row_width + child_width + padding <= layer_width:
+ row_width += child_width + padding
+ row_height = max(row_height, child_height)
+ else:
+ # Center the current row
+ x_offset = (layer_width - row_width + padding) / 2
+ for j in range(row_start_index, i):
+ children[j]["position"]["x"] = x_offset
+ children[j]["position"]["y"] = y_position
+ x_offset += children[j]["size"]["width"] + padding
+
+ # Check if adding a new row exceeds the layer height
+ if y_position + row_height + child_height + padding > layer_height:
+ raise RuntimeError(
+ "Exceeded layer height while arranging child boxes."
+ )
+
+ # Start a new row
+ y_position += row_height + padding
+ row_width = child_width + padding
+ row_height = child_height
+ row_start_index = i
+
+ # Center the last row
+ x_offset = (layer_width - row_width + padding) / 2
+ for j in range(row_start_index, len(children)):
+ children[j]["position"]["x"] = x_offset
+ children[j]["position"]["y"] = y_position
+ x_offset += children[j]["size"]["width"] + padding
diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py
index d9f14e34..674f3d07 100644
--- a/capellambse_context_diagrams/serializers.py
+++ b/capellambse_context_diagrams/serializers.py
@@ -10,6 +10,7 @@
from __future__ import annotations
import logging
+import typing as t
from capellambse import diagram
from capellambse.svg import decorations
@@ -52,7 +53,9 @@ def __init__(self, elk_diagram: context.ContextDiagram) -> None:
self._diagram = elk_diagram
self._cache: dict[str, diagram.Box | diagram.Edge] = {}
- def make_diagram(self, data: _elkjs.ELKOutputData) -> diagram.Diagram:
+ def make_diagram(
+ self, data: _elkjs.ELKOutputData
+ ) -> diagram.Diagram:
"""Transform a layouted diagram into an `diagram.Diagram`.
Parameters
@@ -146,11 +149,16 @@ class type that stores all previously named classes.
self._cache[child["id"]] = element
elif child["type"] == "edge":
styleclass = REMAP_STYLECLASS.get(styleclass, styleclass) # type: ignore[arg-type]
- element = diagram.Edge(
- [
+ if child["routingPoints"]:
+ refpoints = [
ref + (point["x"], point["y"])
for point in child["routingPoints"]
- ],
+ ]
+ else:
+ refpoints = ... # find magical function in aird
+
+ element = diagram.Edge(
+ refpoints,
uuid=child["id"],
source=self.diagram[child["sourceId"]],
target=self.diagram[child["targetId"]],
@@ -164,12 +172,13 @@ class type that stores all previously named classes.
if isinstance(parent, diagram.Box) and not parent.port:
if parent.JSON_TYPE != "symbol":
parent.label = child["text"]
+ parent.styleoverrides |= self.get_styleoverrides(child)
else:
parent.label = diagram.Box(
ref + (child["position"]["x"], child["position"]["y"]),
(child["size"]["width"], child["size"]["height"]),
label=child["text"],
- # parent=parent,
+ styleoverrides=self.get_styleoverrides(child),
)
else:
assert isinstance(parent, diagram.Edge)
@@ -230,21 +239,25 @@ def get_styleclass(self, uuid: str) -> str | None:
def get_styleoverrides(
self, child: _elkjs.ELKOutputChild
- ) -> diagram.StyleOverrides | None:
+ ) -> diagram.StyleOverrides:
"""Return
[`styling.CSSStyles`][capellambse_context_diagrams.styling.CSSStyles]
from a given
[`_elkjs.ELKOutputChild`][capellambse_context_diagrams._elkjs.ELKOutputChild].
"""
style_condition = self._diagram.render_styles.get(child["type"])
- styleoverrides = None
+ styleoverrides: dict[str, t.Any] = {}
if style_condition is not None:
if child["type"] != "junction":
obj = self._diagram._model.by_uuid(child["id"])
else:
obj = None
- styleoverrides = style_condition(obj, self)
+ styleoverrides = style_condition(obj, self) or {}
+
+ style: dict[str, t.Any]
+ if style := child.get("style", {}):
+ styleoverrides |= style
return styleoverrides
def order_children(self) -> None:
diff --git a/tests/data/ContextDiagram.afm b/tests/data/ContextDiagram.afm
index dc32e25b..02f4486c 100644
--- a/tests/data/ContextDiagram.afm
+++ b/tests/data/ContextDiagram.afm
@@ -1,6 +1,6 @@
-
-
-
+
+
+
diff --git a/tests/data/ContextDiagram.capella b/tests/data/ContextDiagram.capella
index abcda219..7a48804d 100644
--- a/tests/data/ContextDiagram.capella
+++ b/tests/data/ContextDiagram.capella
@@ -1536,6 +1536,21 @@ The predator is far away
source="#00e7b925-cf4c-4cb0-929e-5409a1cd872b" target="#85d41db2-9e17-438b-95cf-49342452ddf3"/>
+
+
+
+
+
@@ -2211,6 +2226,9 @@ The predator is far away
+
+
id="e7ea5a90-3b07-499d-b17f-4e61917c850a" name="CP 2" orientation="OUT"
kind="FLOW"/>
+
id="beaf5ba4-8fa9-4342-911f-0266bb29be45" name="advise Harry">
+
@@ -3258,10 +3285,6 @@ The predator is far away
name="Right" abstractType="#37dfa5e6-a121-4ce9-8aa4-09a0c73dc2e9"/>
-
-
@@ -3296,7 +3319,7 @@ The predator is far away
name="School" abstractType="#a58821df-c5b4-4958-9455-0d30755be6b1"/>
+ actor="true">
@@ -3317,6 +3340,12 @@ The predator is far away
+
+
@@ -3481,6 +3510,73 @@ The predator is far away
id="f0e39876-6665-4d6c-9eec-b0c0f1dd8399" targetElement="#b7f2d2ef-3aef-43a9-9309-cf172e50ebb7"
sourceElement="#58f89f0a-7647-4077-b29b-8fe2bf270f02"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -3495,69 +3591,6 @@ The predator is far away
sourceElement="#1286b0fe-dd57-46ba-8303-bd57eb04178d"/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
id="d254c2aa-3fc4-4deb-a684-6191ace32c42" name="Child Function">
+
@@ -3869,6 +3905,9 @@ The predator is far away
+
@@ -3886,6 +3925,9 @@ The predator is far away
id="da53c21f-4fb8-4da7-8d58-12d7a600bfa6" name="Apping around">
+
+ id="b9f9a83c-fb02-44f7-9123-9d86326de5f1" name="Physical System" nature="NODE">
+
@@ -4024,6 +4069,24 @@ The predator is far away
+
+
+
+
+
+