Skip to content

Commit

Permalink
feat(class-tree): Add legend boxes and include properties from super
Browse files Browse the repository at this point in the history
  • Loading branch information
ewuerger committed Oct 31, 2023
1 parent 0c55a58 commit 4702458
Show file tree
Hide file tree
Showing 7 changed files with 888 additions and 107 deletions.
28 changes: 23 additions & 5 deletions capellambse_context_diagrams/_elkjs.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@
NODE_HOME = Path(capellambse.dirs.user_cache_dir, "elkjs", "node_modules")
PATH_TO_ELK_JS = Path(__file__).parent / "elk.js"
REQUIRED_NPM_PKG_VERSIONS: t.Dict[str, str] = {
"elkjs": "0.8.1",
"elkjs": "0.8.2",
}
"""npm package names and versions required by this Python module."""

LayoutOptions = dict[str, t.Union[str, int, float]]
LayoutOptions = cabc.MutableMapping[str, t.Union[str, int, float]]
LAYOUT_OPTIONS: LayoutOptions = {
"algorithm": "layered",
"edgeRouting": "ORTHOGONAL",
Expand All @@ -64,6 +64,24 @@
[get_global_layered_layout_options][capellambse_context_diagrams._elkjs.get_global_layered_layout_options] :
A function that instantiates this class with well-tested settings.
"""
CLASS_TREE_LAYOUT_OPTIONS: LayoutOptions = {
"algorithm": "layered",
"edgeRouting": "ORTHOGONAL",
"elk.direction": "RIGHT",
"layered.edgeLabels.sideSelection": "ALWAYS_DOWN",
"layered.nodePlacement.strategy": "BRANDES_KOEPF",
"spacing.labelNode": "0.0",
"spacing.edgeNode": 20,
"compaction.postCompaction.strategy": "LEFT_RIGHT_CONSTRAINT_LOCKING",
"layered.considerModelOrder.components": "MODEL_ORDER",
"separateConnectedComponents": False,
}
RECT_PACKING_LAYOUT_OPTIONS: LayoutOptions = {
"algorithm": "elk.rectpacking",
"nodeSize.constraints": "[NODE_LABELS, MINIMUM_SIZE]",
"widthApproximation.targetWidth": 1, # width / height
"elk.contentAlignment": "V_TOP H_CENTER",
}
LABEL_LAYOUT_OPTIONS = {"nodeLabels.placement": "OUTSIDE, V_BOTTOM, H_CENTER"}
"""Options for labels to configure ELK layouting."""

Expand All @@ -72,7 +90,7 @@ class ELKInputData(te.TypedDict, total=False):
"""Data that can be fed to ELK."""

id: te.Required[str]
layoutOptions: cabc.MutableMapping[str, t.Union[str, int, float]]
layoutOptions: LayoutOptions
children: cabc.MutableSequence[ELKInputChild] # type: ignore
edges: cabc.MutableSequence[ELKInputEdge]

Expand All @@ -91,7 +109,7 @@ class ELKInputLabel(te.TypedDict, total=False):
"""Label data that can be fed to ELK."""

text: te.Required[str]
layoutOptions: cabc.MutableMapping[str, t.Union[str, int, float]]
layoutOptions: LayoutOptions
width: t.Union[int, float]
height: t.Union[int, float]

Expand All @@ -107,7 +125,7 @@ class ELKInputPort(t.TypedDict):


class ELKInputEdge(te.TypedDict):
"""Exchange data that can be fed to ELK"""
"""Exchange data that can be fed to ELK."""

id: str
sources: cabc.MutableSequence[str]
Expand Down
256 changes: 200 additions & 56 deletions capellambse_context_diagrams/collectors/class_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,101 +5,202 @@

import collections.abc as cabc
import dataclasses
import logging
import math
import typing as t

from capellambse import helpers
from capellambse.model.crosslayer import information

from .. import _elkjs, context
from . import generic, makers

logger = logging.getLogger(__name__)
DATA_TYPE_LABEL_LAYOUT_OPTIONS: _elkjs.LayoutOptions = {
"nodeLabels.placement": "INSIDE, V_CENTER, H_CENTER"
}
DEFAULT_LAYOUT_OPTIONS: _elkjs.LayoutOptions = {
"layered.edgeLabels.sideSelection": "ALWAYS_DOWN",
"algorithm": "layered",
"elk.direction": "DOWN",
"edgeRouting": "ORTHOGONAL",
}


class ClassProcessor:
def __init__(
self,
data: _elkjs.ELKInputData,
all_associations: cabc.Iterable[information.Association],
) -> None:
self.data = data
self.made_boxes: set[str] = {data["children"][0]["id"]}
self.made_edges: set[str] = set()
self.data_types: set[str] = set()
self.legend_boxes: list[_elkjs.ELKInputChild] = []
self.all_associations = all_associations

def process_class(self, cls, params):
self._process_box(cls.target, cls.partition, params)
self._process_box(cls.source, cls.partition, params)
edges = [
assoc
for assoc in self.all_associations
if cls.prop in assoc.navigable_members
]
assert len(edges) == 1
if (edge_id := edges[0].uuid) not in self.made_edges:
self.made_edges.add(edge_id)
text = cls.prop.name
start, end = cls.multiplicity
if start != "1" or end != "1":
text = f"[{start}..{end}] {text}"
self.data["edges"].append(
{
"id": edge_id,
"sources": [cls.source.uuid],
"targets": [cls.target.uuid],
"labels": [makers.make_label(text)],
}
)

if cls.generalizes:
self._process_box(cls.generalizes, cls.partition, params)
edge = cls.generalizes.generalizations.by_super(
cls.source, single=True
)
if edge.uuid not in self.made_edges:
self.made_edges.add(edge.uuid)
self.data["edges"].append(
{
"id": edge.uuid,
"sources": [cls.generalizes.uuid],
"targets": [cls.source.uuid],
}
)

def _process_box(
self, obj: information.Class, partition: int, params: dict[str, t.Any]
) -> None:
if obj.uuid not in self.made_boxes:
self.made_boxes.add(obj.uuid)
box = self._make_box(obj, partition, params)
self._set_data_types_and_labels(box, obj)

def _make_box(
self, obj: information.Class, partition: int, params: dict[str, t.Any]
) -> _elkjs.ELKInputChild:
self.made_boxes.add(obj.uuid)
box = makers.make_box(obj)
self._set_data_types_and_labels(box, obj)
_set_partitioning(box, partition, params)
self.data["children"].append(box)
return box

def _set_data_types_and_labels(
self, box: _elkjs.ELKInputChild, target: information.Class
) -> None:
properties, legends = _get_all_non_edge_properties(
target, self.data_types
)
box["labels"].extend(properties)
box["width"], box["height"] = makers.calculate_height_and_width(
list(box["labels"])
)
self.legend_boxes.extend(legends)


def collector(
diagram: context.ContextDiagram, params: dict[str, t.Any]
) -> _elkjs.ELKInputData:
) -> tuple[_elkjs.ELKInputData, _elkjs.ELKInputData]:
"""Return the class tree data for ELK."""
assert isinstance(diagram.target, information.Class)
data = generic.collector(diagram, no_symbol=True)
if params.get("partitioning", False):
data["layoutOptions"]["partitioning.activate"] = True
data["children"][0]["layoutOptions"] = {}
data["children"][0]["layoutOptions"]["elk.partitioning.partition"] = 0
all_associations: cabc.Iterable[
information.Association
] = diagram._model.search("Association")
_set_layout_options(data, params)
processor = ClassProcessor(data, all_associations)
processor._set_data_types_and_labels(data["children"][0], diagram.target)
for _, cls in get_all_classes(diagram.target):
processor.process_class(cls, params)

data["layoutOptions"]["edgeLabels.sideSelection"] = params.get(
"edgeLabelsSide", "ALWAYS_DOWN"
)
data["layoutOptions"]["algorithm"] = params.get("algorithm", "layered")
data["layoutOptions"]["elk.direction"] = params.get("direction", "DOWN")
data["layoutOptions"]["edgeRouting"] = params.get(
"edgeRouting", "ORTHOGONAL"
)
legend = makers.make_diagram(diagram)
legend["layoutOptions"] = _elkjs.RECT_PACKING_LAYOUT_OPTIONS
legend["children"] = processor.legend_boxes
return data, legend

made_boxes: set[str] = set()
for _, cls in get_all_classes(diagram.target):
if cls.target.uuid not in made_boxes:
made_boxes.add(cls.target.uuid)
box = makers.make_box(cls.target)
if params.get("partitioning", False):
box["layoutOptions"] = {}
box["layoutOptions"]["elk.partitioning.partition"] = int(
cls.partition
)
data["children"].append(box)

text = cls.prop.name
start, end = cls.multiplicity
if start != "1" or end != "1":
text = f"[{start}..{end}] {text}"

width, height = helpers.extent_func(text)
label: _elkjs.ELKInputLabel = {
"text": text,
"width": width + 2 * makers.LABEL_HPAD,
"height": height + 2 * makers.LABEL_VPAD,
}
data["edges"].append(
{
"id": cls.prop.uuid,
"sources": [cls.source.uuid],
"targets": [cls.target.uuid],
"labels": [label],
}
)
return data

def _set_layout_options(
data: _elkjs.ELKInputData, params: dict[str, t.Any]
) -> None:
data["layoutOptions"] = {**DEFAULT_LAYOUT_OPTIONS, **params}
_set_partitioning(data["children"][0], 0, params)


def _set_partitioning(box, partition: int, params: dict[str, t.Any]) -> None:
if params.get("partitioning", False):
box["layoutOptions"] = {}
box["layoutOptions"]["elk.partitioning.partition"] = partition


@dataclasses.dataclass
class ClassInfo:
"""All information needed for a ``Class`` box."""

source: information.Class
target: information.Class
prop: information.Property
partition: int
multiplicity: tuple[str, str]
generalizes: information.Class | None = None


def get_all_classes(
root: information.Class, partition: int = 0
root: information.Class,
partition: int = 0,
classes: dict[str, ClassInfo] | None = None,
) -> cabc.Iterator[tuple[str, ClassInfo]]:
"""Yield all classes of the class tree."""
partition += 1
visited_classes = set()
classes: dict[str, ClassInfo] = {}
for prop in root.properties:
if prop.type.xtype.endswith("Class"):
if prop.uuid in visited_classes:
classes = classes or {}
for prop in root.owned_properties:
if not (prop.type and prop.type.xtype.endswith("Class")):
logger.warning(
"Property without abstract type found: %r", prop._short_repr_()
)
continue

edge_id = f"{root.uuid} {prop.uuid} {prop.type.uuid}"
if edge_id not in classes:
classes[edge_id] = _make_class_info(root, prop, partition)
classes.update(
dict(get_all_classes(prop.type, partition, classes))
)
if root.super is not None and (properties := root.super.owned_properties):
for prop in properties:
if not (prop.type and prop.type.xtype.endswith("Class")):
logger.warning(
"Property without abstract type found: %r",
prop._short_repr_(),
)
continue
visited_classes.add(prop.uuid)

edge_id = f"{root.uuid} {prop.uuid} {prop.type.uuid}"
if edge_id not in classes:
classes[edge_id] = _make_class_info(root, prop, partition)
classes.update(dict(get_all_classes(prop.type, partition)))
classes[edge_id] = _make_class_info(
root.super, prop, partition, generalizes=root
)
classes.update(
dict(get_all_classes(prop.type, partition, classes))
)
yield from classes.items()


def _make_class_info(
source: information.Class, prop: information.Property, partition: int
source: information.Class,
prop: information.Property,
partition: int,
generalizes: information.Class | None = None,
) -> ClassInfo:
converter = {math.inf: "*"}
start = converter.get(prop.min_card.value, str(prop.min_card.value))
Expand All @@ -110,4 +211,47 @@ def _make_class_info(
prop=prop,
partition=partition,
multiplicity=(start, end),
generalizes=generalizes,
)


def _get_all_non_edge_properties(
obj: information.Class, data_types: set[str]
) -> tuple[list[_elkjs.ELKInputLabel], list[_elkjs.ELKInputChild]]:
layout_options = DATA_TYPE_LABEL_LAYOUT_OPTIONS
properties: list[_elkjs.ELKInputLabel] = []
legends: list[_elkjs.ELKInputChild] = []
for prop in obj.properties:
if isinstance(prop.type, (information.Class, type(None))):
continue

text = f"{prop.name}: {prop.type.name}" # type: ignore[unreachable]
label = makers.make_label(text, layout_options=layout_options)
properties.append(label)

if prop.type.uuid in data_types:
continue

data_types.add(prop.type.uuid)
if not isinstance(prop.type, information.datatype.Enumeration):
continue
legend = makers.make_box(prop.type, label_getter=_get_legend_labels)
legend["layoutOptions"] = {}
legend["layoutOptions"]["nodeSize.constraints"] = "NODE_LABELS"
legends.append(legend)
return properties, legends


def _get_legend_labels(
obj: information.datatype.Enumeration,
) -> cabc.Iterator[makers._LabelBuilder]:
yield {"text": obj.name, "icon": (0, 0), "layout_options": {}}
if not isinstance(obj, information.datatype.Enumeration):
return
layout_options = DATA_TYPE_LABEL_LAYOUT_OPTIONS
for lit in obj.literals:
yield {
"text": lit.name,
"icon": (0, 0),
"layout_options": layout_options,
}
Loading

0 comments on commit 4702458

Please sign in to comment.