Skip to content

Commit

Permalink
merge: Merge pull request #42 from DSD-DBS/fix-display-symbols-as-boxes
Browse files Browse the repository at this point in the history
feat: Add custom style and filter to SystemAnalysis diagrams
  • Loading branch information
ewuerger authored May 8, 2023
2 parents 9e3bc20 + d80d61f commit eafb682
Show file tree
Hide file tree
Showing 15 changed files with 149 additions and 43 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/build-test-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ jobs:
include:
- os: windows-latest
python_version: "3.9"
env:
PYTHONUTF8: 1
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{matrix.python_version}}
Expand Down
1 change: 1 addition & 0 deletions capellambse_context_diagrams/collectors/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def collector(
data["children"].extend(global_boxes.values())
if child_boxes:
centerbox["children"] = child_boxes
centerbox["width"] = makers.EOI_WIDTH

centerbox["height"] = max(centerbox["height"], *stack_heights.values())
return data
Expand Down
7 changes: 6 additions & 1 deletion capellambse_context_diagrams/collectors/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,12 @@ def collector(
"""Returns ``ELKInputData`` with only centerbox in children and config."""
data = makers.make_diagram(diagram)
data["children"] = [
makers.make_box(diagram.target, width=width, no_symbol=no_symbol)
makers.make_box(
diagram.target,
width=width,
no_symbol=no_symbol,
slim_width=diagram.slim_center_box,
)
]
return data

Expand Down
4 changes: 3 additions & 1 deletion capellambse_context_diagrams/collectors/makers.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ def make_box(
width: int | float = 0,
height: int | float = 0,
no_symbol: bool = False,
slim_width: bool = False,
) -> _elkjs.ELKInputChild:
"""Return an
[`ELKInputChild`][capellambse_context_diagrams._elkjs.ELKInputChild].
Expand All @@ -81,7 +82,8 @@ def make_box(
height,
sum(label["height"] for label in labels) + icon,
)
width = max(width, max(label["width"] for label in labels) + icon)
min_width = max(label["width"] for label in labels) + icon
width = min_width if slim_width else max(width, min_width)

return {"id": obj.uuid, "labels": labels, "width": width, "height": height}

Expand Down
11 changes: 4 additions & 7 deletions capellambse_context_diagrams/collectors/portless.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def collector(
architecture layer diagrams (diagrams where elements don't exchange
via ports/connectors).
"""
data = generic.collector(diagram)
data = generic.collector(diagram, no_symbol=True)
centerbox = data["children"][0]
connections = list(get_exchanges(diagram.target))
for ex in connections:
Expand Down Expand Up @@ -63,7 +63,9 @@ def collector(
box["height"] = height
else:
box = makers.make_box(
i, height=height, no_symbol=diagram.display_symbols_as_boxes
i,
height=height,
no_symbol=diagram.display_symbols_as_boxes,
)
made_boxes[i.uuid] = box

Expand All @@ -72,15 +74,10 @@ def collector(
del made_boxes[centerbox["id"]]
data["children"].extend(made_boxes.values())
centerbox["height"] = max(centerbox["height"], *stack_heights.values())
centerbox["width"] = (
max(label["width"] for label in centerbox["labels"])
+ 2 * makers.LABEL_HPAD
)
if not diagram.display_symbols_as_boxes and makers.is_symbol(
diagram.target
):
data["layoutOptions"]["spacing.labelNode"] = 5.0
centerbox["width"] = centerbox["height"] * makers.SYMBOL_RATIO
return data


Expand Down
19 changes: 19 additions & 0 deletions capellambse_context_diagrams/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@

logger = logging.getLogger(__name__)

STANDARD_FILTERS = {
"Operational Capabilities Blank": filters.SYSTEM_EX_RELABEL,
"Missions Capabilities Blank": filters.SYSTEM_EX_RELABEL,
}
STANDARD_STYLES = {
"Operational Capabilities Blank": styling.SYSTEM_CAP_STYLING,
"Missions Capabilities Blank": styling.SYSTEM_CAP_STYLING,
}


class ContextAccessor(common.Accessor):
"""Provides access to the context diagrams."""
Expand Down Expand Up @@ -140,6 +149,9 @@ class ContextDiagram(diagram.AbstractDiagram):
avoids the object of interest to become one giant, oversized
symbol in the middle of the diagram, and instead keeps the
symbol small and only enlarges the surrounding box.
slim_center_box
Minimal width for the center box, containing just the icon and
the label. This is False if hierarchy was identified.
serializer
The serializer builds a `diagram.Diagram` via
[`serializers.DiagramSerializer.make_diagram`][capellambse_context_diagrams.serializers.DiagramSerializer.make_diagram]
Expand All @@ -160,6 +172,7 @@ def __init__(
render_styles: dict[str, styling.Styler] | None = None,
display_symbols_as_boxes: bool = False,
include_inner_objects: bool = False,
slim_center_box: bool = True,
) -> None:
super().__init__(obj._model)
self.target = obj
Expand All @@ -170,6 +183,12 @@ def __init__(
self.__filters: cabc.MutableSet[str] = self.FilterSet(self)
self.display_symbols_as_boxes = display_symbols_as_boxes
self.include_inner_objects = include_inner_objects
self.slim_center_box = slim_center_box

if standard_filter := STANDARD_FILTERS.get(class_):
self.filters.add(standard_filter)
if standard_styles := STANDARD_STYLES.get(class_):
self.render_styles = standard_styles

@property
def uuid(self) -> str: # type: ignore
Expand Down
40 changes: 32 additions & 8 deletions capellambse_context_diagrams/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,29 +12,42 @@
from capellambse.model import common

FEX_EX_ITEMS = "show.functional.exchanges.exchange.items.filter"
"""
Show the name of `FunctionalExchange` and its `ExchangeItems` wrapped in
[E1,...] and seperated by ',' - filter in Capella.
"""Show the name of `FunctionalExchange` and its `ExchangeItems` wrapped in
[E1,...] and separated by ',' - filter in Capella.
"""
EX_ITEMS = "show.exchange.items.filter"
"""
Show `ExchangeItems` wrapped in [E1,...] and seperated by ',' - filter
"""Show `ExchangeItems` wrapped in [E1,...] and separated by ',' - filter
in Capella.
"""
FEX_OR_EX_ITEMS = "capellambse_context_diagrams-show.functional.exchanges.or.exchange.items.filter"
"""
Show either `FunctionalExchange` name or its `ExchangeItems` wrapped in
[E1,...] and seperated by ',' - Custom filter, not available in Capella.
"""Show either `FunctionalExchange` name or its `ExchangeItems` wrapped in
[E1,...] and separated by ',' - Custom filter, not available in Capella.
"""
NO_UUID = "capellambse_context_diagrams-hide.uuids.filter"
"""Filter out UUIDs from label text."""
SYSTEM_EX_RELABEL = (
"capellambse_context_diagrams-relabel.system.analysis.exchange"
)
"""Relabel exchanges from the SystemAnalysis layer. E.g. « i » is converted to
includes or involves, based on the type."""


logger = logging.getLogger(__name__)

UUID_PTRN = re.compile(
r"\s*\([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\)"
)
"""Regular expression pattern for UUIDs of `ModelObject`s."""
LABEL_CONVERSION: t.Final[dict[str, str]] = {
"AbstractCapabilityExtend": "extends",
"AbstractCapabilityGeneralization": "specializes",
"AbstractCapabilityInclude": "includes",
"CapabilityExploitation": "exploits",
"CapabilityInvolvement": "involves",
"EntityOperationalCapabilityInvolvement": "involves",
"MissionInvolvement": "involves",
}
"""A map that for relabelling specific ModelObject types."""


def exchange_items(obj: common.GenericElement) -> str:
Expand Down Expand Up @@ -64,6 +77,16 @@ def uuid_filter(obj: common.GenericElement, label: str | None = None) -> str:
return UUID_PTRN.sub("", filtered_label)


def relabel_system_exchange(
obj: common.GenericElement, label: str | None
) -> str:
"""Return converted label from obj, a system exchanges."""
label_map = LABEL_CONVERSION
if patch := label_map.get(type(obj).__name__):
return f"« {patch} »"
return label or obj.name


FILTER_LABEL_ADJUSTERS: dict[
str, cabc.Callable[[common.GenericElement, str | None], str]
] = {
Expand All @@ -73,6 +96,7 @@ def uuid_filter(obj: common.GenericElement, label: str | None = None) -> str:
if getattr(obj, "exchange_items", "")
else label or obj.name,
NO_UUID: uuid_filter,
SYSTEM_EX_RELABEL: relabel_system_exchange,
}
"""Label adjuster registry. """

Expand Down
2 changes: 1 addition & 1 deletion capellambse_context_diagrams/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ def get_styleoverrides(
else:
obj = None

styleoverrides = style_condition(obj)
styleoverrides = style_condition(obj, self)
return styleoverrides

def order_children(self) -> None:
Expand Down
34 changes: 27 additions & 7 deletions capellambse_context_diagrams/styling.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
from capellambse.diagram import capstyle
from capellambse.model import common

if t.TYPE_CHECKING:
from . import serializers

CSSStyles = t.Union[diagram.StyleOverrides, None]
"""
A dictionary with CSS styles. The keys are the attribute names and the
Expand All @@ -22,15 +25,17 @@
[parent_is_actor_fills_blue][capellambse_context_diagrams.styling.parent_is_actor_fills_blue]
"""
Styler = t.Callable[
[common.GenericElement], t.Union[diagram.StyleOverrides, None]
[common.GenericElement, "serializers.DiagramSerializer"],
t.Union[diagram.StyleOverrides, None],
]
"""Function that produces `CSSStyles` for given obj."""


def parent_is_actor_fills_blue(obj: common.GenericElement) -> CSSStyles:
"""
Returns `CSSStyles` for given obj (i.e. `common.GenericElement`).
"""
def parent_is_actor_fills_blue(
obj: common.GenericElement, serializer: serializers.DiagramSerializer
) -> CSSStyles:
"""Return ``CSSStyles`` for given ``obj`` rendering it blue."""
del serializer
try:
if obj.owner.is_actor:
return {
Expand All @@ -46,9 +51,24 @@ def parent_is_actor_fills_blue(obj: common.GenericElement) -> CSSStyles:
return None


def style_center_symbol(
obj: common.GenericElement, serializer: serializers.DiagramSerializer
) -> CSSStyles:
"""Return ``CSSStyles`` for given ``obj``."""
if obj != serializer._diagram.target: # type: ignore[has-type]
return None
return {
"fill": capstyle.COLORS["white"],
"stroke": capstyle.COLORS["gray"],
"stroke-dasharray": 3,
}


BLUE_ACTOR_FNCS: dict[str, Styler] = {"node": parent_is_actor_fills_blue}
"""
CSSStyle for coloring Actor Functions (Functions of Components with
"""CSSStyle for coloring Actor Functions (Functions of Components with
the attribute `is_actor` set to `True`) with a blue gradient like in
Capella.
"""
SYSTEM_CAP_STYLING: dict[str, Styler] = {"node": style_center_symbol}
"""CSSStyle for custom styling of SystemAnalysis diagrams. The center
box is drawn with a white background and a grey dashed line."""
6 changes: 5 additions & 1 deletion docs/extras/styling.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ You can switch to py-capellambse default styling by overriding the
<figcaption>Context diagram of educate Wizards LogicalFunction w/o any styles</figcaption>
</figure>

You probably noticed that the SystemAnalysis diagrams on the index page have
custom styling. There we applied the [SYSTEM_EX_RELABEL][capellambse_context_diagrams.filters.SYSTEM_EX_RELABEL] filter
and [SYSTEM_CAP_STYLING][capellambse_context_diagrams.styling.SYSTEM_CAP_STYLING] style. These styles are applied per default.

Style your diagram elements ([ElkChildType][capellambse_context_diagrams.serializers.ElkChildType]) arbitrarily:

??? example "Red junction point"
Expand All @@ -120,7 +124,7 @@ Style your diagram elements ([ElkChildType][capellambse_context_diagrams.seriali
diag = model.by_uuid("957c5799-1d4a-4ac0-b5de-33a65bf1519c").context_diagram
diag.render_styles = dict(
styling.BLUE_ACTOR_FNCS,
**{"junction": lambda _: {"fill": aird.RGB(220, 20, 60)}},
junction=lambda obj, serializer: {"fill": aird.RGB(220, 20, 60)},
)
diag.render("svgdiagram").save_drawing(True)
```
Expand Down
10 changes: 5 additions & 5 deletions docs/gen_images.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ def generate_no_symbol_images() -> None:


def generate_no_edgelabel_image(uuid: str) -> None:
diagram: context.ContextDiagram = model.by_uuid(uuid).context_diagram
diagram.invalidate_cache()
filename = " ".join((str(dest / diagram.name), "no_edgelabels"))
cdiagram: context.ContextDiagram = model.by_uuid(uuid).context_diagram
cdiagram.invalidate_cache()
filename = " ".join((str(dest / cdiagram.name), "no_edgelabels"))
with mkdocs_gen_files.open(f"{filename}.svg", "w") as fd:
print(diagram.render("svg", no_edgelabels=True), file=fd)
print(cdiagram.render("svg", no_edgelabels=True), file=fd)


def generate_filter_image(
Expand Down Expand Up @@ -107,7 +107,7 @@ def generate_hierarchy_image() -> None:
lost,
dict(
styling.BLUE_ACTOR_FNCS,
**{"junction": lambda _: {"fill": diagram.RGB(220, 20, 60)}}, # type: ignore
junction=lambda o, s: {"fill": diagram.RGB(220, 20, 60)},
),
"red junction",
)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ enable = [
addopts = """
--strict-config
--strict-markers
--import-mode=importlib
"""
testpaths = ["tests"]
xfail_strict = true
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@

TEST_ROOT = pathlib.Path(__file__).parent / "data"
TEST_MODEL = "ContextDiagram.aird"
SYSTEM_ANALYSIS_PARAMS = [
pytest.param(
"da08ddb6-92ba-4c3b-956a-017424dbfe85", id="OperationalCapability"
),
pytest.param("9390b7d5-598a-42db-bef8-23677e45ba06", id="Capability"),
pytest.param("5bf3f1e3-0f5e-4fec-81d5-c113d3a1b3a6", id="Mission"),
]


@pytest.fixture
Expand Down
16 changes: 5 additions & 11 deletions tests/test_capability_diagrams.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,17 @@
import pytest
from capellambse.model.layers import ctx, oa

# pylint: disable-next=relative-beyond-top-level, useless-suppression
from .conftest import SYSTEM_ANALYSIS_PARAMS # type: ignore[import]

TEST_TYPES = (oa.OperationalCapability, ctx.Capability, ctx.Mission)


@pytest.mark.parametrize(
"uuid",
[
pytest.param(
"da08ddb6-92ba-4c3b-956a-017424dbfe85", id="OperationalCapability"
),
pytest.param("9390b7d5-598a-42db-bef8-23677e45ba06", id="Capability"),
pytest.param("5bf3f1e3-0f5e-4fec-81d5-c113d3a1b3a6", id="Mission"),
],
)
@pytest.mark.parametrize("uuid", SYSTEM_ANALYSIS_PARAMS)
def test_context_diagrams(model: capellambse.MelodyModel, uuid: str) -> None:
obj = model.by_uuid(uuid)
assert isinstance(obj, TEST_TYPES), "Precondition failed"

diag = obj.context_diagram

assert isinstance(obj, TEST_TYPES)
assert diag.nodes
Loading

0 comments on commit eafb682

Please sign in to comment.