diff --git a/capellambse_context_diagrams/__init__.py b/capellambse_context_diagrams/__init__.py
index e3786726..25ed39ac 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 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)
+ ```
+
+
+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