diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2fda50e5..1d3d5abd 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 376ba2d2..48ddd949 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,45 @@ 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), + ] + styles: dict[str, dict[str, capstyle.CSSdef]] = {} + for class_, dgcls in supported_classes: + common.set_accessor( + class_, + "realization_view", + context.RealizationViewContextAccessor("RealizationView Diagram"), + ) + styles.update(capstyle.STYLES.get(dgcls.value, {})) + + capstyle.STYLES["RealizationView Diagram"] = styles + capstyle.STYLES["RealizationView Diagram"].update( + capstyle.STYLES["__GLOBAL__"] + ) + capstyle.STYLES["RealizationView Diagram"]["Edge.Realization"] = { + "stroke": capstyle.COLORS["dark_gray"], + "marker-end": "FineArrowMark", + "stroke-dasharray": "5", + } diff --git a/capellambse_context_diagrams/_elkjs.py b/capellambse_context_diagrams/_elkjs.py index 8ed87aad..b84053e0 100644 --- a/capellambse_context_diagrams/_elkjs.py +++ b/capellambse_context_diagrams/_elkjs.py @@ -43,7 +43,7 @@ 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.9.0", } """npm package names and versions required by this Python module.""" @@ -149,18 +149,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 +174,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 +193,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 +203,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/realization_view.py b/capellambse_context_diagrams/collectors/realization_view.py new file mode 100644 index 00000000..eef6ab06 --- /dev/null +++ b/capellambse_context_diagrams/collectors/realization_view.py @@ -0,0 +1,213 @@ +# 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 in ("Operational", "System", "Logical", "Physical"): + if not (elements := lay_to_els.get(layer)): # type: ignore[call-overload] + continue + + 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: + assert elt["element"] is not None + 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 elt.get("reverse", False): + source = elt["element"] + target = elt["origin"] + else: + source = elt["origin"] + target = elt["element"] + + if not (element_box := children.get(target.uuid)): + element_box = makers.make_box(target, no_symbol=True) + children[target.uuid] = element_box + layer_box["children"].append(element_box) + index = len(layer_box["children"]) - 1 + + if params.get("show_owners"): + owner = target.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 ( + source is not None + and source.owner.uuid in children + and owner.uuid in children + ): + eid = f"{source.owner.uuid}_{owner.uuid}" + edges.append( + { + "id": eid, + "sources": [source.owner.uuid], + "targets": [owner.uuid], + } + ) + + data["children"].append(layer_box) + 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]]] = {} + if direction == "ABOVE" or origin is None: + collected_elements = { + layer: [{"element": start, "origin": origin, "layer": layer_obj}] + } + elif direction == "BELOW" and origin is not None: + collected_elements = { + layer: [ + { + "element": origin, + "origin": start, + "layer": layer_obj, + "reverse": True, + } + ] + } + + 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, +} +"""The functions to receive the diagram elements for every layer.""" diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index b9970fe7..6833115a 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -13,10 +13,11 @@ 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 +143,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 +151,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 +296,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 = try_to_layout(data) return self.serializer.make_diagram(layout) @property # type: ignore @@ -379,6 +396,100 @@ 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"Realization 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 = 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 = try_to_layout(data) + for edge in edges: + layout["children"].append( + { + "id": edge["id"], + "type": "edge", + "sourceId": edge["sources"][0], + "targetId": edge["targets"][0], + "routingPoints": [], + "styleclass": "Realization", + } # type: ignore[arg-type] + ) + 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} + + +def try_to_layout(data: _elkjs.ELKInputData) -> _elkjs.ELKOutputData: + """Try calling elkjs, raise a JSONDecodeError if it fails.""" + try: + return _elkjs.call_elkjs(data) + except json.JSONDecodeError as error: + logger.error(json.dumps(data, indent=4)) + raise error + + def stack_diagrams( first: cdiagram.Diagram, second: cdiagram.Diagram, @@ -391,3 +502,32 @@ 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) diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py index cc6593a9..cbfb7eb3 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 @@ -145,12 +146,20 @@ class type that stores all previously named classes. self.diagram.add_element(element) self._cache[child["id"]] = element elif child["type"] == "edge": + styleclass = child.get("styleclass", styleclass) # type: ignore[assignment] 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: + source = self._cache[child["sourceId"]] + target = self._cache[child["targetId"]] + refpoints = route_shortest_connection(source, target) + + element = diagram.Edge( + refpoints, uuid=child["id"], source=self.diagram[child["sourceId"]], target=self.diagram[child["targetId"]], @@ -164,12 +173,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 +240,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: @@ -277,3 +291,24 @@ def handle_features(child: _elkjs.ELKOutputNode) -> list[str]: features.append(c["text"]) child["children"] = child["children"][:1] return features + + +def route_shortest_connection( + source: diagram.Box, + target: diagram.Box, +) -> list[diagram.Vector2D]: + """Calculate shortest path between boxes with 'Oblique' style. + + Calculate the intersection points of the line from source.center to + target.center with the bounding boxes of the source and target. + """ + line_start = source.center + line_end = target.center + + source_intersection = source.vector_snap( + line_start, source=line_end, style=diagram.RoutingStyle.OBLIQUE + ) + target_intersection = target.vector_snap( + line_end, source=line_start, style=diagram.RoutingStyle.OBLIQUE + ) + return [source_intersection, target_intersection] diff --git a/docs/gen_images.py b/docs/gen_images.py index d7873d72..2dae034f 100644 --- a/docs/gen_images.py +++ b/docs/gen_images.py @@ -33,6 +33,8 @@ hierarchy_context = "16b4fcc5-548d-4721-b62a-d3d5b1c1d2eb" diagram_uuids = general_context_diagram_uuids | interface_context_diagram_uuids class_tree_uuid = "b7c7f442-377f-492c-90bf-331e66988bda" +realization_fnc_uuid = "beaf5ba4-8fa9-4342-911f-0266bb29be45" +realization_comp_uuid = "b9f9a83c-fb02-44f7-9123-9d86326de5f1" def generate_index_images() -> None: @@ -112,6 +114,22 @@ def generate_class_tree_images() -> None: ) +def generate_realization_view_images() -> None: + for uuid in (realization_fnc_uuid, realization_comp_uuid): + obj = model.by_uuid(uuid) + diag = obj.realization_view + with mkdocs_gen_files.open(f"{str(dest / diag.name)}.svg", "w") as fd: + print( + diag.render( + "svg", + depth=3, + search_direction="ALL", + show_owners=True, + ), + file=fd, + ) + + generate_index_images() generate_hierarchy_image() generate_no_symbol_images() @@ -134,3 +152,4 @@ def generate_class_tree_images() -> None: ) generate_styling_image(wizard, {}, "no_styles") generate_class_tree_images() +generate_realization_view_images() diff --git a/docs/realization_view.md b/docs/realization_view.md new file mode 100644 index 00000000..8ba377a1 --- /dev/null +++ b/docs/realization_view.md @@ -0,0 +1,70 @@ + + +# Tree View Diagram + +With release +[`v0.5.42`](https://github.com/DSD-DBS/py-capellambse/releases/tag/v0.5.42) of +[py-capellambse](https://github.com/DSD-DBS/py-capellambse) you can access the +`.realization_view` on a Component or Function from any layer. A realization +view diagram reveals the realization map that the layers of your model +implement currently. The diagram elements are collected from the +`.realized_components` or `.realized_functions` attribute for the direction +`ABOVE` and `.realizing_components` or `.realizing_functions` for direction +`BELOW`. + +??? example "Realization View Diagram of `LogicalFunction` `advise Harry`" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("beaf5ba4-8fa9-4342-911f-0266bb29be45").realization_view + diag.render( + "svgdiagram", + depth=3, # 1-3 + search_direction="ALL", # BELOW; ABOVE and ALL + show_owners=True, + ).save_drawing(pretty=True) + ``` +
+ +
[CDB] Realization View Diagram of advise Harry
+
+ +??? example "Realization View Diagram of `PhysicalComponent` `Physical System`" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("b9f9a83c-fb02-44f7-9123-9d86326de5f1").realization_view + diag.render( + "svgdiagram", + depth=3, + search_direction="ALL", + show_owners=True, + ).save_drawing(pretty=True) + ``` +
+ +
[CDB] Realization View Diagram of Physical System
+
+ +Additional rendering parameters enable showing owning functions or components, +as well as the depth of traversion (i.e. `1`-`3`). They are put to display the +maximum amount of diagram elements per default. + +??? bug "Alignment of diagram elements" + + As of [elkjs@0.9.0](https://eclipse.dev/elk/downloads/releasenotes/release-0.9.0.html) ELK's rectpacking algorithm isn't correctly using the + content alignment enumeration. While developing the Realization View + [a fix for the horizontal alignment was proposed](https://github.com/eclipse/elk/issues/989). + +## Check out the code + +To understand the collection have a look into the +[`realization_view`][capellambse_context_diagrams.collectors.realization_view] +module. diff --git a/mkdocs.yml b/mkdocs.yml index b0c0201e..88d9ead7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -91,6 +91,8 @@ nav: - Styling: extras/styling.md - Tree View: - Overview: tree_view.md + - Realization View: + - Overview: realization_view.md - Code Reference: reference/ extra_css: 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 + + + + + + None: + obj = model.by_uuid(uuid) + + diag = obj.realization_view + + assert diag.render(fmt) + + +@pytest.mark.parametrize("uuid", [TEST_FNC_UUID, TEST_CMP_UUID]) +@pytest.mark.parametrize("depth", list(range(1, 4))) +@pytest.mark.parametrize("search_direction", ["ABOVE", "BELOW"]) +@pytest.mark.parametrize("show_owners", [True, False]) +def test_tree_view_renders_with_additional_params( + model: capellambse.MelodyModel, + depth: int, + search_direction: str, + show_owners: bool, + uuid: str, +) -> None: + obj = model.by_uuid(uuid) + + diag = obj.realization_view + + assert diag.render( + "svgdiagram", + depth=depth, + search_direction=search_direction, + show_owners=show_owners, + )