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 + + + + + +