diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index 11d35713..5aa3e1b4 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -28,6 +28,7 @@ from capellambse.model.crosslayer import fa, information from capellambse.model.layers import ctx, la, oa, pa from capellambse.model.modeltypes import DiagramType +from capellambse.svg import decorations from . import _elkjs, context, styling diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py index fbe4de4b..8d351c2e 100644 --- a/capellambse_context_diagrams/collectors/default.py +++ b/capellambse_context_diagrams/collectors/default.py @@ -12,16 +12,23 @@ from capellambse import helpers from capellambse.model import common from capellambse.model.crosslayer import cs, fa +from capellambse.model.layers import ctx as sa +from capellambse.model.layers import la from capellambse.model.modeltypes import DiagramType as DT from .. import _elkjs, context from . import exchanges, generic, makers +STYLECLASS_PREFIX = "__Derived" + def collector( diagram: context.ContextDiagram, params: dict[str, t.Any] | None = None ) -> _elkjs.ELKInputData: """Collect context data from ports of centric box.""" + diagram.display_derived_interfaces = (params or {}).pop( + "display_derived_interfaces", diagram.display_derived_interfaces + ) data = generic.collector(diagram, no_symbol=True) ports = port_collector(diagram.target, diagram.type) centerbox = data["children"][0] @@ -119,6 +126,9 @@ def collector( ) centerbox["height"] = max(centerbox["height"], *stack_heights.values()) + if diagram.display_derived_interfaces: + add_derived_components_and_interfaces(diagram, data) + return data @@ -259,3 +269,91 @@ def port_context_collector( info.ports.append(port) return iter(ctx.values()) + + +def add_derived_components_and_interfaces( + diagram: context.ContextDiagram, data: _elkjs.ELKInputData +) -> None: + """Add hidden Boxes and Exchanges to ``obj``'s context. + + The derived exchanges are displayed with a dashed line. + """ + if derivator := DERIVATORS.get(type(diagram.target)): + derivator(diagram, data) + + +def derive_from_functions( + diagram: context.ContextDiagram, data: _elkjs.ELKInputData +) -> None: + """Derive Components from allocated functions of the context target. + + A Component, a ComponentExchange and two ComponentPorts are added + to ``data``. These elements are prefixed with ``Derived-`` to + receive special styling in the serialization step. + """ + assert isinstance(diagram.target, cs.Component) + ports = [] + for fnc in diagram.target.allocated_functions: + ports.extend(port_collector(fnc, diagram.type)) + + context_box_ids = {child["id"] for child in data["children"]} + components: dict[str, cs.Component] = {} + for port in ports: + for fex in port.exchanges: + if isinstance(port, fa.FunctionOutputPort): + attr = "target" + else: + attr = "source" + + try: + derived_comp = getattr(fex, attr).owner.owner + if ( + derived_comp == diagram.target + or derived_comp.uuid in context_box_ids + ): + continue + + if derived_comp.uuid not in components: + components[derived_comp.uuid] = derived_comp + except AttributeError: # No owner of owner. + pass + + # Idea: Include flow direction of derived interfaces from all functional + # exchanges. Mixed means bidirectional. Just even out bidirectional + # interfaces and keep flow direction of others. + + centerbox = data["children"][0] + for i, (uuid, derived_component) in enumerate(components.items(), 1): + box = makers.make_box( + derived_component, + no_symbol=diagram.display_symbols_as_boxes, + ) + class_ = type(derived_comp).__name__ + box["id"] = f"{STYLECLASS_PREFIX}-{class_}:{uuid}" + data["children"].append(box) + source_id = f"{STYLECLASS_PREFIX}-CP_INOUT:{i}" + target_id = f"{STYLECLASS_PREFIX}-CP_INOUT:{-i}" + box.setdefault("ports", []).append(makers.make_port(source_id)) + centerbox.setdefault("ports", []).append(makers.make_port(target_id)) + if i % 2 == 0: + source_id, target_id = target_id, source_id + + data["edges"].append( + { + "id": f"{STYLECLASS_PREFIX}-ComponentExchange:{i}", + "sources": [source_id], + "targets": [target_id], + } + ) + + data["children"][0]["height"] += ( + makers.PORT_PADDING + + (makers.PORT_SIZE + makers.PORT_PADDING) * len(components) // 2 + ) + + +DERIVATORS = { + la.LogicalComponent: derive_from_functions, + sa.SystemComponent: derive_from_functions, +} +"""Supported objects to build derived contexts for.""" diff --git a/capellambse_context_diagrams/collectors/makers.py b/capellambse_context_diagrams/collectors/makers.py index 672c3869..fdea3a12 100644 --- a/capellambse_context_diagrams/collectors/makers.py +++ b/capellambse_context_diagrams/collectors/makers.py @@ -42,10 +42,12 @@ FAULT_PAD = 10 """Height adjustment for labels.""" BOX_TO_SYMBOL = ( - layers.ctx.Capability, - layers.oa.OperationalCapability, - layers.ctx.Mission, - layers.ctx.SystemComponent, + layers.ctx.Capability.__name__, + layers.oa.OperationalCapability.__name__, + layers.ctx.Mission.__name__, + layers.ctx.SystemComponent.__name__, + "SystemHumanActor", + "SystemActor", ) """ Types that need to be converted to symbols during serialization if @@ -189,9 +191,13 @@ def calculate_height_and_width( return width, max(height, _height) -def is_symbol(obj: common.GenericElement) -> bool: +def is_symbol(obj: str | common.GenericElement | None) -> bool: """Check if given `obj` is rendered as a Symbol instead of a Box.""" - return isinstance(obj, BOX_TO_SYMBOL) + if obj is None: + return False + elif isinstance(obj, str): + return obj in BOX_TO_SYMBOL + return type(obj).__name__ in BOX_TO_SYMBOL def make_port(uuid: str) -> _elkjs.ELKInputPort: diff --git a/capellambse_context_diagrams/collectors/tree_view.py b/capellambse_context_diagrams/collectors/tree_view.py index 081d1eec..d9d1698c 100644 --- a/capellambse_context_diagrams/collectors/tree_view.py +++ b/capellambse_context_diagrams/collectors/tree_view.py @@ -16,6 +16,7 @@ from . import generic, makers logger = logging.getLogger(__name__) + DATA_TYPE_LABEL_LAYOUT_OPTIONS: _elkjs.LayoutOptions = { "nodeLabels.placement": "INSIDE, V_CENTER, H_CENTER" } @@ -25,6 +26,7 @@ "elk.direction": "DOWN", "edgeRouting": "ORTHOGONAL", } +ASSOC_STYLECLASS = "__Association" class ClassProcessor: @@ -58,7 +60,7 @@ def process_class(self, cls: ClassInfo, params: dict[str, t.Any]): if len(edges) == 1: edge_id = edges[0].uuid else: - edge_id = f"__Association_{self.edge_counter}" + edge_id = f"{ASSOC_STYLECLASS}_{self.edge_counter}" self.edge_counter += 1 if edge_id not in self.made_edges: self.made_edges.add(edge_id) diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index a49becac..a672d365 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -232,6 +232,9 @@ class ContextDiagram(diagram.AbstractDiagram): display_parent_relation Display objects with a parent relationship to the object of interest as the parent box. + display_derived_interfaces + Display derived objects collected from additional collectors + beside the main collector for building the context. slim_center_box Minimal width for the center box, containing just the icon and the label. This is False if hierarchy was identified. @@ -255,6 +258,7 @@ def __init__( render_styles: dict[str, styling.Styler] | None = None, display_symbols_as_boxes: bool = False, display_parent_relation: bool = False, + display_derived_interfaces: bool = False, include_inner_objects: bool = False, slim_center_box: bool = True, ) -> None: @@ -267,6 +271,7 @@ def __init__( self.__filters: cabc.MutableSet[str] = self.FilterSet(self) self.display_symbols_as_boxes = display_symbols_as_boxes self.display_parent_relation = display_parent_relation + self.display_derived_interfaces = display_derived_interfaces self.include_inner_objects = include_inner_objects self.slim_center_box = slim_center_box diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py index c7b0a3a2..760a43b7 100644 --- a/capellambse_context_diagrams/serializers.py +++ b/capellambse_context_diagrams/serializers.py @@ -17,7 +17,8 @@ from capellambse import diagram from capellambse.svg import decorations -from . import _elkjs, collectors, context +from . import _elkjs, context +from .collectors import makers logger = logging.getLogger(__name__) @@ -112,27 +113,28 @@ def deserialize_child( [`diagram.Diagram`][capellambse.diagram.Diagram] : Diagram class type that stores all previously named classes. """ + uuid: str + styleclass: str | None + derived = False if child["id"].startswith("__"): - styleclass: str | None = child["id"][2:].split("_", 1)[0] + styleclass, uuid = child["id"][2:].split(":", 1) + if styleclass.startswith("Derived-"): + styleclass = styleclass.removeprefix("Derived-") + derived = True else: styleclass = self.get_styleclass(child["id"]) + uuid = child["id"] + + styleoverrides = self.get_styleoverrides(uuid, child, derived=derived) element: diagram.Box | diagram.Edge | diagram.Circle if child["type"] in {"node", "port"}: assert parent is None or isinstance(parent, diagram.Box) - has_symbol_cls = False - try: - obj = self.model.by_uuid(child["id"]) - has_symbol_cls = collectors.makers.is_symbol(obj) - except KeyError: - logger.error( - "ModelObject could not be found: '%s'", child["id"] - ) - + has_symbol_cls = makers.is_symbol(styleclass) is_port = child["type"] == "port" box_type = ("box", "symbol")[ is_port or has_symbol_cls - and not self._diagram.target == obj + and not self._diagram.target.uuid == uuid and not self._diagram.display_symbols_as_boxes ] @@ -145,48 +147,56 @@ class type that stores all previously named classes. element = diagram.Box( ref, size, - uuid=child["id"], + uuid=uuid, parent=parent, port=is_port, styleclass=styleclass, - styleoverrides=self.get_styleoverrides(child), + styleoverrides=styleoverrides, features=features, context=child.get("context"), ) element.JSON_TYPE = box_type self.diagram.add_element(element) - self._cache[child["id"]] = element + self._cache[uuid] = element elif child["type"] == "edge": styleclass = child.get("styleclass", styleclass) # type: ignore[assignment] styleclass = REMAP_STYLECLASS.get(styleclass, styleclass) # type: ignore[arg-type] EDGE_HANDLER.get(styleclass, lambda c: c)(child) + source_id = child["sourceId"] + if source_id.startswith("__"): + source_id = source_id[2:].split(":", 1)[-1] + + target_id = child["targetId"] + if target_id.startswith("__"): + target_id = target_id[2:].split(":", 1)[-1] + 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"]] + source = self._cache[source_id] + target = self._cache[target_id] refpoints = route_shortest_connection(source, target) element = diagram.Edge( refpoints, uuid=child["id"], - source=self.diagram[child["sourceId"]], - target=self.diagram[child["targetId"]], + source=self.diagram[source_id], + target=self.diagram[target_id], styleclass=styleclass, - styleoverrides=self.get_styleoverrides(child), + styleoverrides=styleoverrides, context=child.get("context"), ) self.diagram.add_element(element) - self._cache[child["id"]] = element + self._cache[uuid] = element elif child["type"] == "label": assert parent is not None if not parent.port: if parent.JSON_TYPE != "symbol": - parent.styleoverrides |= self.get_styleoverrides(child) + parent.styleoverrides |= styleoverrides if isinstance(parent, diagram.Box): attr_name = "floating_labels" @@ -211,7 +221,7 @@ class type that stores all previously named classes. + (child["position"]["x"], child["position"]["y"]), (child["size"]["width"], child["size"]["height"]), label=child["text"], - styleoverrides=self.get_styleoverrides(child), + styleoverrides=styleoverrides, ) ) @@ -228,7 +238,7 @@ class type that stores all previously named classes. 5, uuid=child["id"], styleclass=self.get_styleclass(uuid), - styleoverrides=self.get_styleoverrides(child), + styleoverrides=styleoverrides, context=child.get("context"), ) self.diagram.add_element(element) @@ -260,12 +270,12 @@ def get_styleclass(self, uuid: str) -> str | None: except KeyError: if not uuid.startswith("__"): return None - return uuid[2:].split("_", 1)[0] + return uuid[2:].split(":", 1)[0] else: return diagram.get_styleclass(melodyobj) def get_styleoverrides( - self, child: _elkjs.ELKOutputChild + self, uuid: str, child: _elkjs.ELKOutputChild, *, derived: bool = False ) -> diagram.StyleOverrides: """Return [`styling.CSSStyles`][capellambse_context_diagrams.styling.CSSStyles] @@ -276,12 +286,15 @@ def get_styleoverrides( styleoverrides: dict[str, t.Any] = {} if style_condition is not None: if child["type"] != "junction": - obj = self._diagram._model.by_uuid(child["id"]) + obj = self._diagram._model.by_uuid(uuid) else: obj = None styleoverrides = style_condition(obj, self) or {} + if derived: + styleoverrides["stroke-dasharray"] = "4" + style: dict[str, t.Any] if style := child.get("style", {}): styleoverrides |= style diff --git a/docs/extras/derived.md b/docs/extras/derived.md new file mode 100644 index 00000000..796833f4 --- /dev/null +++ b/docs/extras/derived.md @@ -0,0 +1,37 @@ + + +# Derived diagram elements + +With capellambse-context-diagrams +[`v0.2.36`](https://github.com/DSD-DBS/capellambse-context-diagrams/releases/tag/v0.2.36) +a separate context is built. The elements are derived from the diagram target, +i.e. the system of interest on which `context_diagram` was called on. The +render parameter to enable this feature is called `display_derived_interfaces` +and is available on: + +- `LogicalComponent`s and +- `SystemComponent`s + +!!! example "Context Diagram with derived elements" + + ```py + from capellambse import MelodyModel + + lost = model.by_uuid("0d18f31b-9a13-4c54-9e63-a13dbf619a69") + diag = obj.context_diagram + diag.render( + "svgdiagram", display_derived_interfaces=True + ).save(pretty=True) + ``` +
+ +
Context diagram of Center with derived context
+
+ +See [`the derivator +functions`][capellambse_context_diagrams.collectors.default.DERIVATORS] to gain +an overview over all supported capellambse types and the logic to derive +elements. diff --git a/docs/gen_images.py b/docs/gen_images.py index 6a61734a..d48e173c 100644 --- a/docs/gen_images.py +++ b/docs/gen_images.py @@ -41,6 +41,7 @@ realization_fnc_uuid = "beaf5ba4-8fa9-4342-911f-0266bb29be45" realization_comp_uuid = "b9f9a83c-fb02-44f7-9123-9d86326de5f1" data_flow_uuid = "3b83b4ba-671a-4de8-9c07-a5c6b1d3c422" +derived_uuid = "0d18f31b-9a13-4c54-9e63-a13dbf619a69" def generate_index_images() -> None: @@ -159,6 +160,18 @@ def generate_data_flow_image() -> None: print(diag.render("svg", transparent_background=False), file=fd) +def generate_derived_image() -> None: + diag: context.ContextDiagram = model.by_uuid(derived_uuid).context_diagram + params = { + "display_derived_interfaces": True, + "transparent_background": False, + } + with mkdocs_gen_files.open( + f"{str(dest / diag.name)}-derived.svg", "w" + ) as fd: + print(diag.render("svg", **params), file=fd) + + generate_index_images() generate_hierarchy_image() generate_no_symbol_images() @@ -183,3 +196,4 @@ def generate_data_flow_image() -> None: generate_class_tree_images() generate_realization_view_images() generate_data_flow_image() +generate_derived_image() diff --git a/mkdocs.yml b/mkdocs.yml index 11b56c02..7ff20180 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,11 +89,12 @@ nav: - Extras: - Filters: extras/filters.md - Styling: extras/styling.md + - 🔥 Derived 🔥: extras/derived.md - Tree View: - Overview: tree_view.md - Realization View: - Overview: realization_view.md - - 🔥 DataFlow View 🔥: + - DataFlow View: - Overview: data_flow_view.md - Code Reference: reference/ diff --git a/tests/data/ContextDiagram.aird b/tests/data/ContextDiagram.aird index 809f2d97..ca8830e5 100644 --- a/tests/data/ContextDiagram.aird +++ b/tests/data/ContextDiagram.aird @@ -78,7 +78,7 @@ - +
@@ -94,6 +94,14 @@ + + +
+
+ + + +
@@ -7773,7 +7781,7 @@ - + @@ -8964,6 +8972,513 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + KEEP_LOCATION + KEEP_SIZE + KEEP_RATIO + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/data/ContextDiagram.capella b/tests/data/ContextDiagram.capella index 6cede71c..1a673425 100644 --- a/tests/data/ContextDiagram.capella +++ b/tests/data/ContextDiagram.capella @@ -2944,6 +2944,33 @@ The predator is far away + + + + + + + + + + + + + + + + + + + @@ -3191,6 +3227,10 @@ The predator is far away name="Multiport" abstractType="#b3888dad-a870-4b8b-97d4-0ddb83ef9251"/> + + + name="Right 1" abstractType="#c999f0f0-49d0-4b01-b260-f69dc63abbcb"/> + + + id="b557cfc6-ca9a-40f5-b6f7-14e61523bd9a" name="CP 1" orientation="IN" kind="FLOW"/> + + + + + + + + + + + id="3e0a3791-cc99-4af5-b789-5b33451ca743" name="CP 1" orientation="OUT" kind="FLOW"/> + + + + + + None: + obj = model.by_uuid(TEST_DERIVED_UUID) + + context_diagram = obj.context_diagram + derived_diagram = context_diagram.render( + None, display_derived_interfaces=True + ) + + assert len(derived_diagram) > 5