From bf7a801ce591cfb6ea8b3fafda61bf404d5da616 Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <48179958+huyenngn@users.noreply.github.com> Date: Tue, 7 May 2024 15:11:03 +0200 Subject: [PATCH 1/3] feat: Include nested comps in interface context Solves (#93). --------- Co-authored-by: ewuerger --- capellambse_context_diagrams/_elkjs.py | 1 + .../collectors/exchanges.py | 159 ++- capellambse_context_diagrams/context.py | 9 +- docs/data_flow_view.md | 2 +- docs/gen_images.py | 52 +- docs/index.md | 19 +- tests/data/ContextDiagram.aird | 923 ++++++++++++++++-- tests/data/ContextDiagram.capella | 140 ++- tests/test_interface_diagrams.py | 22 + 9 files changed, 1161 insertions(+), 166 deletions(-) diff --git a/capellambse_context_diagrams/_elkjs.py b/capellambse_context_diagrams/_elkjs.py index 7a7bc2c1..337588b1 100644 --- a/capellambse_context_diagrams/_elkjs.py +++ b/capellambse_context_diagrams/_elkjs.py @@ -56,6 +56,7 @@ "hierarchyHandling": "INCLUDE_CHILDREN", "layered.edgeLabels.sideSelection": "ALWAYS_DOWN", "layered.nodePlacement.strategy": "BRANDES_KOEPF", + "layered.considerModelOrder.strategy": "NODES_AND_EDGES", "spacing.labelNode": "0.0", } """ diff --git a/capellambse_context_diagrams/collectors/exchanges.py b/capellambse_context_diagrams/collectors/exchanges.py index f4eaab46..83f64933 100644 --- a/capellambse_context_diagrams/collectors/exchanges.py +++ b/capellambse_context_diagrams/collectors/exchanges.py @@ -9,6 +9,7 @@ import typing as t from capellambse.model import common +from capellambse.model.crosslayer import cs from capellambse.model.modeltypes import DiagramType as DT from .. import _elkjs, context @@ -65,31 +66,60 @@ def get_functions_and_exchanges( self, comp: common.GenericElement, interface: common.GenericElement ) -> tuple[ list[common.GenericElement], - list[common.GenericElement], - list[common.GenericElement], + dict[str, common.GenericElement], + dict[str, common.GenericElement], ]: """Return `Function`s, incoming and outgoing `FunctionalExchange`s for given `Component` and `interface`. """ - functions, outgoings, incomings = [], [], [] + functions, incomings, outgoings = [], {}, {} alloc_functions = self.get_alloc_functions(comp) for fex in self.get_alloc_fex(interface): source = self.get_source(fex) if source in alloc_functions: - if fex not in outgoings: - outgoings.append(fex) + if fex.uuid not in outgoings: + outgoings[fex.uuid] = fex if source not in functions: functions.append(source) target = self.get_target(fex) if target in alloc_functions: - if fex not in incomings: - incomings.append(fex) + if fex.uuid not in incomings: + incomings[fex.uuid] = fex if target not in functions: functions.append(target) return functions, incomings, outgoings + def collect_context( + self, comp: common.GenericElement, interface: common.GenericElement + ) -> tuple[ + dict[str, t.Any], + dict[str, common.GenericElement], + dict[str, common.GenericElement], + ]: + functions, incomings, outgoings = self.get_functions_and_exchanges( + comp, interface + ) + components = [] + for cmp in comp.components: + fncs, _, _ = self.get_functions_and_exchanges(cmp, interface) + functions.extend(fncs) + if fncs: + c, incs, outs = self.collect_context(cmp, interface) + components.append(c) + incomings |= incs + outgoings |= outs + return ( + { + "element": comp, + "functions": functions, + "components": components, + }, + incomings, + outgoings, + ) + def make_ports_and_update_children_size( self, data: _elkjs.ELKInputChild, @@ -100,6 +130,9 @@ def make_ports_and_update_children_size( for child in data["children"]: inputs, outputs = [], [] obj = self.obj._model.by_uuid(child["id"]) + if isinstance(obj, cs.Component): + self.make_ports_and_update_children_size(child, exchanges) + return port_ids = {p.uuid for p in obj.inputs + obj.outputs} for ex in exchanges: source, target = ex["sources"][0], ex["targets"][0] @@ -153,12 +186,12 @@ class InterfaceContextCollector(ExchangeCollector): for building the interface context. """ - left: common.GenericElement - """Source or target Component of the interface.""" - right: common.GenericElement - """Source or target Component of the interface.""" - outgoing_edges: list[common.GenericElement] - incoming_edges: list[common.GenericElement] + left: _elkjs.ELKInputChild | None + """Left (source) Component Box of the interface.""" + right: _elkjs.ELKInputChild | None + """Right (target) Component Box of the interface.""" + outgoing_edges: dict[str, common.GenericElement] + incoming_edges: dict[str, common.GenericElement] def __init__( self, @@ -166,8 +199,16 @@ def __init__( data: _elkjs.ELKInputData, params: dict[str, t.Any], ) -> None: + self.left = None + self.right = None + self.incoming_edges = {} + self.outgoing_edges = {} + super().__init__(diagram, data, params) + self.get_left_and_right() + if diagram.include_interface: + self.add_interface() def get_left_and_right(self) -> None: made_children: set[str] = set() @@ -178,15 +219,19 @@ def get_capella_order( alloc_functions = self.get_alloc_functions(comp) return [fnc for fnc in alloc_functions if fnc in functions] - def make_boxes( - comp: common.GenericElement, functions: list[common.GenericElement] - ) -> None: + def make_boxes(cntxt: dict[str, t.Any]) -> _elkjs.ELKInputChild | None: + comp = cntxt["element"] + functions = cntxt["functions"] + components = cntxt["components"] if comp.uuid not in made_children: children = [ - makers.make_box(c) - for c in functions - if c in self.get_alloc_functions(comp) + makers.make_box(fnc) + for fnc in functions + if fnc in self.get_alloc_functions(comp) ] + for cmp in components: + if child := make_boxes(cmp): + children.append(child) if children: layout_options = makers.DEFAULT_LABEL_LAYOUT_OPTIONS else: @@ -196,52 +241,68 @@ def make_boxes( comp, no_symbol=True, layout_options=layout_options ) box["children"] = children - self.data["children"].append(box) made_children.add(comp.uuid) + return box + return None try: comp = self.get_source(self.obj) - functions, incs, outs = self.get_functions_and_exchanges( - comp, self.obj - ) - inc_port_ids = set(ex.target.uuid for ex in incs) - out_port_ids = set(ex.source.uuid for ex in outs) + left_context, incs, outs = self.collect_context(comp, self.obj) + inc_port_ids = set(ex.target.uuid for ex in incs.values()) + out_port_ids = set(ex.source.uuid for ex in outs.values()) port_spread = len(out_port_ids) - len(inc_port_ids) _comp = self.get_target(self.obj) - _functions, _, _ = self.get_functions_and_exchanges( - _comp, self.obj - ) - _inc_port_ids = set(ex.target.uuid for ex in outs) - _out_port_ids = set(ex.source.uuid for ex in incs) + right_context, _, _ = self.collect_context(_comp, self.obj) + _inc_port_ids = set(ex.target.uuid for ex in outs.values()) + _out_port_ids = set(ex.source.uuid for ex in incs.values()) _port_spread = len(_out_port_ids) - len(_inc_port_ids) - functions = get_capella_order(comp, functions) - _functions = get_capella_order(_comp, _functions) + left_context["functions"] = get_capella_order( + comp, left_context["functions"] + ) + right_context["functions"] = get_capella_order( + _comp, right_context["functions"] + ) if port_spread >= _port_spread: - self.left = comp - self.right = _comp - self.outgoing_edges = outs self.incoming_edges = incs - left_functions = functions - right_functions = _functions + self.outgoing_edges = outs else: - self.left = _comp - self.right = comp - self.outgoing_edges = incs self.incoming_edges = outs - left_functions = _functions - right_functions = functions - - make_boxes(self.left, left_functions) - make_boxes(self.right, right_functions) + self.outgoing_edges = incs + left_context, right_context = right_context, left_context + + if left_child := make_boxes(left_context): + self.data["children"].append(left_child) + self.left = left_child + if right_child := make_boxes(right_context): + self.data["children"].append(right_child) + self.right = right_child except AttributeError: pass + def add_interface(self) -> None: + ex_data = generic.ExchangeData( + self.obj, + self.data, + self.diagram.filters, + self.params, + is_hierarchical=False, + ) + src, tgt = generic.exchange_data_collector(ex_data) + assert self.right is not None + if self.get_source(self.obj).uuid == self.right["id"]: + self.data["edges"][-1]["sources"] = [tgt.uuid] + self.data["edges"][-1]["targets"] = [src.uuid] + + assert self.left is not None + self.left.setdefault("ports", []).append(makers.make_port(src.uuid)) + self.right.setdefault("ports", []).append(makers.make_port(tgt.uuid)) + def collect(self) -> None: - """Return all allocated `FunctionalExchange`s in the context.""" + """Collect all allocated `FunctionalExchange`s in the context.""" try: - for ex in self.incoming_edges + self.outgoing_edges: + for ex in (self.incoming_edges | self.outgoing_edges).values(): ex_data = generic.ExchangeData( ex, self.data, @@ -251,7 +312,7 @@ def collect(self) -> None: ) src, tgt = generic.exchange_data_collector(ex_data) - if ex in self.incoming_edges: + if ex in self.incoming_edges.values(): self.data["edges"][-1]["sources"] = [tgt.uuid] self.data["edges"][-1]["targets"] = [src.uuid] @@ -300,7 +361,7 @@ def collect(self) -> None: made_children.add(comp.uuid) all_functions.extend(functions) - functional_exchanges.extend(inc + outs) + functional_exchanges.extend(inc | outs) self.data["children"][0]["children"] = [ makers.make_box(c) diff --git a/capellambse_context_diagrams/context.py b/capellambse_context_diagrams/context.py index 174c6d77..a49becac 100644 --- a/capellambse_context_diagrams/context.py +++ b/capellambse_context_diagrams/context.py @@ -363,7 +363,14 @@ class InterfaceContextDiagram(ContextDiagram): ``ComponentExchange``s. """ - def __init__(self, class_: str, obj: common.GenericElement, **kw) -> None: + def __init__( + self, + class_: str, + obj: common.GenericElement, + include_interface: bool = False, + **kw, + ) -> None: + self.include_interface = include_interface super().__init__(class_, obj, **kw, display_symbols_as_boxes=True) @property diff --git a/docs/data_flow_view.md b/docs/data_flow_view.md index fa101353..9c9127e2 100644 --- a/docs/data_flow_view.md +++ b/docs/data_flow_view.md @@ -24,7 +24,7 @@ The diagram elements are collected from the diag.as_svgdiagram.save(pretty=True) ```
- +
[OAIB] DataFlow View Diagram of Eat food
diff --git a/docs/gen_images.py b/docs/gen_images.py index c09bfe8a..6a61734a 100644 --- a/docs/gen_images.py +++ b/docs/gen_images.py @@ -5,6 +5,7 @@ import logging import pathlib +import typing as t import mkdocs_gen_files from capellambse import MelodyModel, diagram @@ -16,19 +17,23 @@ dest = pathlib.Path("assets") / "images" model_path = pathlib.Path(__file__).parent.parent / "tests" / "data" model = MelodyModel(path=model_path, entrypoint="ContextDiagram.aird") -general_context_diagram_uuids = { - "Environment": "e37510b9-3166-4f80-a919-dfaac9b696c7", - "Eat": "8bcb11e6-443b-4b92-bec2-ff1d87a224e7", - "Middle": "da08ddb6-92ba-4c3b-956a-017424dbfe85", - "Capability": "9390b7d5-598a-42db-bef8-23677e45ba06", - "Lost": "a5642060-c9cc-4d49-af09-defaa3024bae", - "Left": "f632888e-51bc-4c9f-8e81-73e9404de784", - "educate Wizards": "957c5799-1d4a-4ac0-b5de-33a65bf1519c", - "Weird guy": "098810d9-0325-4ae8-a111-82202c0d2016", - "Top secret": "5bf3f1e3-0f5e-4fec-81d5-c113d3a1b3a6", +general_context_diagram_uuids: dict[str, tuple[str, dict[str, t.Any]]] = { + "Environment": ("e37510b9-3166-4f80-a919-dfaac9b696c7", {}), + "Eat": ("8bcb11e6-443b-4b92-bec2-ff1d87a224e7", {}), + "Middle": ("da08ddb6-92ba-4c3b-956a-017424dbfe85", {}), + "Capability": ("9390b7d5-598a-42db-bef8-23677e45ba06", {}), + "Lost": ("a5642060-c9cc-4d49-af09-defaa3024bae", {}), + "Left": ("f632888e-51bc-4c9f-8e81-73e9404de784", {}), + "educate Wizards": ("957c5799-1d4a-4ac0-b5de-33a65bf1519c", {}), + "Weird guy": ("098810d9-0325-4ae8-a111-82202c0d2016", {}), + "Top secret": ("5bf3f1e3-0f5e-4fec-81d5-c113d3a1b3a6", {}), } -interface_context_diagram_uuids = { - "Left to right": "3ef23099-ce9a-4f7d-812f-935f47e7938d", +interface_context_diagram_uuids: dict[str, tuple[str, dict[str, t.Any]]] = { + "Left to right": ("3ef23099-ce9a-4f7d-812f-935f47e7938d", {}), + "Interface": ( + "fbb7f735-3c1f-48de-9791-179d35ca7b98", + {"include_interface": True}, + ), } hierarchy_context = "16b4fcc5-548d-4721-b62a-d3d5b1c1d2eb" diagram_uuids = general_context_diagram_uuids | interface_context_diagram_uuids @@ -39,15 +44,16 @@ def generate_index_images() -> None: - for uuid in diagram_uuids.values(): + for uuid, render_params in diagram_uuids.values(): diag: context.ContextDiagram = model.by_uuid(uuid).context_diagram with mkdocs_gen_files.open(f"{str(dest / diag.name)}.svg", "w") as fd: - print(diag.render("svg", transparent_background=False), file=fd) + render_params["transparent_background"] = False # type: ignore[index] + print(diag.render("svg", **render_params), file=fd) # type: ignore[arg-type] def generate_no_symbol_images() -> None: for name in ("Capability", "Middle"): - uuid = general_context_diagram_uuids[name] + uuid, _ = general_context_diagram_uuids[name] diag: context.ContextDiagram = model.by_uuid(uuid).context_diagram diag.display_symbols_as_boxes = True diag.invalidate_cache() @@ -157,23 +163,23 @@ def generate_data_flow_image() -> None: generate_hierarchy_image() generate_no_symbol_images() -wizard = general_context_diagram_uuids["educate Wizards"] -generate_no_edgelabel_image(wizard) +wizard_uuid = general_context_diagram_uuids["educate Wizards"][0] +generate_no_edgelabel_image(wizard_uuid) -lost = general_context_diagram_uuids["Lost"] -generate_filter_image(lost, filters.EX_ITEMS, "ex") -generate_filter_image(lost, filters.FEX_EX_ITEMS, "fex and ex") -generate_filter_image(lost, filters.FEX_OR_EX_ITEMS, "fex or ex") +lost_uuid = general_context_diagram_uuids["Lost"][0] +generate_filter_image(lost_uuid, filters.EX_ITEMS, "ex") +generate_filter_image(lost_uuid, filters.FEX_EX_ITEMS, "fex and ex") +generate_filter_image(lost_uuid, filters.FEX_OR_EX_ITEMS, "fex or ex") generate_styling_image( - lost, + lost_uuid, dict( styling.BLUE_ACTOR_FNCS, junction=lambda o, s: {"stroke": diagram.RGB(220, 20, 60)}, ), "red junction", ) -generate_styling_image(wizard, {}, "no_styles") +generate_styling_image(wizard_uuid, {}, "no_styles") generate_class_tree_images() generate_realization_view_images() generate_data_flow_image() diff --git a/docs/index.md b/docs/index.md index 93ab4d46..93f44bfb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,8 +17,8 @@ base class with [`ContextDiagram`s][capellambse_context_diagrams.context.Context Generate **Context Diagrams** from your model data!
- -
Interface context diagram of Left to right
+ +
Interface context diagram of Interface
## Features @@ -192,6 +192,21 @@ The data is collected by [get_elkdata_for_exchanges][capellambse_context_diagram
Interface context diagram of Left to right LogicalComponentExchange with type [LAB]
+??? example "Include the interface the ([`fa.ComponentExchange`][capellambse.model.crosslayer.fa.ComponentExchange])" + + ``` py + import capellambse + + model = capellambse.MelodyModel("tests/data/ContextDiagram.aird") + diag = model.by_uuid("fbb7f735-3c1f-48de-9791-179d35ca7b98").context_diagram + diag.render("svgdiagram", include_interface=True).save(pretty=True) + ``` +
+ +
Interface context diagram of Interface LogicalComponentExchange with type [LAB]
+
+ + !!! warning "Interface context only supported for the LogicalComponentExchanges" ### Customized edge routing diff --git a/tests/data/ContextDiagram.aird b/tests/data/ContextDiagram.aird index 1ab52215..809f2d97 100644 --- a/tests/data/ContextDiagram.aird +++ b/tests/data/ContextDiagram.aird @@ -54,7 +54,7 @@ - +
@@ -62,7 +62,7 @@ - +
@@ -94,6 +94,14 @@ + + +
+
+ + + + @@ -2033,7 +2041,7 @@ - + @@ -2044,7 +2052,7 @@ - + @@ -2055,10 +2063,10 @@ - + - + @@ -2075,7 +2083,7 @@ - + @@ -2120,22 +2128,6 @@ - - - - - - - - - - - - - - - - @@ -2154,17 +2146,17 @@ - + - + - + - + @@ -2216,22 +2208,6 @@ - - - - - - - - - - - - - - - - @@ -2250,17 +2226,17 @@ - + - + - + - + @@ -2282,17 +2258,17 @@ - + - + - + @@ -2314,17 +2290,17 @@ - + - + - + - + @@ -2440,6 +2416,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -2492,7 +2500,7 @@ - + @@ -2533,7 +2541,7 @@ - + @@ -2663,7 +2671,7 @@ - + @@ -2673,7 +2681,7 @@ - + @@ -2683,7 +2691,7 @@ - + @@ -2747,7 +2755,7 @@ - + @@ -2757,7 +2765,7 @@ - + @@ -2912,17 +2920,6 @@ - - - - - - - - - - - @@ -2960,6 +2957,28 @@ + + + + + + + + + + + + + + + + + + + + + + @@ -3417,15 +3436,6 @@ - - - - - - - - - @@ -3453,6 +3463,24 @@ + + + + + + + + + + + + + + + + + + KEEP_LOCATION KEEP_SIZE KEEP_RATIO @@ -3500,8 +3528,8 @@ KEEP_LOCATION KEEP_SIZE KEEP_RATIO - - + + @@ -8936,4 +8964,733 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 dd452ea4..6cede71c 100644 --- a/tests/data/ContextDiagram.capella +++ b/tests/data/ContextDiagram.capella @@ -2944,6 +2944,35 @@ The predator is far away + + + + + + + + + + + + + + + + + @@ -2981,20 +3010,20 @@ The predator is far away id="0fe9e999-3bd8-4590-9dc2-f51d2c1af173" name="FunctionalExchange 11" target="#494979bd-d301-419b-8a91-fd1c28dec21c" source="#aaf1f13c-270f-4d67-bc65-37a1c9afb17a"/> - + @@ -3031,6 +3060,18 @@ The predator is far away + + + + + + + + + + name="Right 1" abstractType="#c999f0f0-49d0-4b01-b260-f69dc63abbcb"/> + + @@ -3764,6 +3825,71 @@ The predator is far away id="b557cfc6-ca9a-40f5-b6f7-14e61523bd9a" name="CP 1" orientation="IN" kind="FLOW"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + None: + obj = model.by_uuid(TEST_INTERFACE_UUID) + + diag = obj.context_diagram + + assert diag.nodes + + +def test_interface_diagram_with_included_interface( + model: capellambse.MelodyModel, +) -> None: + obj = model.by_uuid(TEST_INTERFACE_UUID) + + diag = obj.context_diagram.render(None, include_interface=True) + + assert diag[TEST_INTERFACE_UUID] From 79c0cf496557f7920f2128773e4d033d2b61f0c4 Mon Sep 17 00:00:00 2001 From: Martin Lehmann Date: Mon, 13 May 2024 18:21:05 +0200 Subject: [PATCH 2/3] feat: Add a helper to install elk.js ahead of time (#97) --- capellambse_context_diagrams/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py index e3786726..11d35713 100644 --- a/capellambse_context_diagrams/__init__.py +++ b/capellambse_context_diagrams/__init__.py @@ -29,7 +29,7 @@ from capellambse.model.layers import ctx, la, oa, pa from capellambse.model.modeltypes import DiagramType -from . import context, styling +from . import _elkjs, context, styling try: __version__ = metadata.version("capellambse-context-diagrams") @@ -46,6 +46,18 @@ ATTR_NAME = "context_diagram" +def install_elk() -> None: + """Install elk.js and its dependencies into the local cache directory. + + When rendering a context diagram, elk.js will be installed + automatically into a persistent local cache directory. This function + may be called while building a container, starting a server or + similar tasks in order to prepare the elk.js execution environment + ahead of time. + """ + _elkjs._install_required_npm_pkg_versions() + + def init() -> None: """Initialize the extension.""" register_classes() From 128ecaa0e8a0f4351d201aec748d07bb30dd2746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ernst=20W=C3=BCrger?= Date: Wed, 15 May 2024 09:32:35 +0200 Subject: [PATCH 3/3] feat: Feat derived interfaces (#94) --- capellambse_context_diagrams/__init__.py | 1 + .../collectors/default.py | 98 ++++ .../collectors/makers.py | 18 +- .../collectors/tree_view.py | 4 +- capellambse_context_diagrams/context.py | 5 + capellambse_context_diagrams/serializers.py | 67 ++- docs/extras/derived.md | 37 ++ docs/gen_images.py | 14 + mkdocs.yml | 3 +- tests/data/ContextDiagram.aird | 519 +++++++++++++++++- tests/data/ContextDiagram.capella | 85 +++ tests/test_context_diagrams.py | 14 + 12 files changed, 828 insertions(+), 37 deletions(-) create mode 100644 docs/extras/derived.md 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