From 3960e313cb84bf8a6eb1cbafead9d51f132962ca Mon Sep 17 00:00:00 2001 From: ewuerger Date: Mon, 18 Mar 2024 14:28:39 +0100 Subject: [PATCH] feat!: Add automatic label wrapping This prevents way too long labels and therefore very wide boxes. Set per default. --- .../collectors/default.py | 10 +++ .../collectors/exchanges.py | 32 ++++++-- .../collectors/makers.py | 80 ++++++++++++++----- .../collectors/portless.py | 2 +- .../collectors/realization_view.py | 13 ++- .../collectors/tree_view.py | 36 ++++++--- capellambse_context_diagrams/serializers.py | 18 ++--- tests/test_labels.py | 25 +++--- 8 files changed, 154 insertions(+), 62 deletions(-) diff --git a/capellambse_context_diagrams/collectors/default.py b/capellambse_context_diagrams/collectors/default.py index 32a47771..738e00ad 100644 --- a/capellambse_context_diagrams/collectors/default.py +++ b/capellambse_context_diagrams/collectors/default.py @@ -50,6 +50,7 @@ def collector( box = makers.make_box( diagram.target.parent, no_symbol=diagram.display_symbols_as_boxes, + layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, ) box["children"] = [centerbox] del data["children"][0] @@ -96,6 +97,11 @@ def collector( parent_box.setdefault("children", []).append( global_boxes.pop(child.uuid) ) + for label in parent_box["labels"]: + label[ + "layoutOptions" + ] = makers.CENTRIC_LABEL_LAYOUT_OPTIONS + _move_edge_to_local_edges( parent_box, connections, local_ports, diagram, data ) @@ -107,6 +113,10 @@ def collector( if child_boxes: centerbox["children"] = child_boxes centerbox["width"] = makers.EOI_WIDTH + for label in centerbox.get("labels", []): + label.setdefault("layoutOptions", {}).update( + makers.DEFAULT_LABEL_LAYOUT_OPTIONS + ) centerbox["height"] = max(centerbox["height"], *stack_heights.values()) return data diff --git a/capellambse_context_diagrams/collectors/exchanges.py b/capellambse_context_diagrams/collectors/exchanges.py index 4835d601..4f34d517 100644 --- a/capellambse_context_diagrams/collectors/exchanges.py +++ b/capellambse_context_diagrams/collectors/exchanges.py @@ -44,8 +44,9 @@ class ExchangeCollector(metaclass=abc.ABCMeta): def __init__( self, - diagram: context.InterfaceContextDiagram - | context.FunctionalContextDiagram, + diagram: ( + context.InterfaceContextDiagram | context.FunctionalContextDiagram + ), data: _elkjs.ELKInputData, params: dict[str, t.Any], ) -> None: @@ -130,8 +131,9 @@ def collect(self) -> None: def get_elkdata_for_exchanges( - diagram: context.InterfaceContextDiagram - | context.FunctionalContextDiagram, + diagram: ( + context.InterfaceContextDiagram | context.FunctionalContextDiagram + ), collector_type: type[ExchangeCollector], params: dict[str, t.Any], ) -> _elkjs.ELKInputData: @@ -180,12 +182,20 @@ def make_boxes( comp: common.GenericElement, functions: list[common.GenericElement] ) -> None: if comp.uuid not in made_children: - box = makers.make_box(comp, no_symbol=True) - box["children"] = [ + children = [ makers.make_box(c) for c in functions if c in self.get_alloc_functions(comp) ] + if children: + layout_options = makers.DEFAULT_LABEL_LAYOUT_OPTIONS + else: + layout_options = makers.CENTRIC_LABEL_LAYOUT_OPTIONS + + box = makers.make_box( + comp, no_symbol=True, layout_options=layout_options + ) + box["children"] = children self.data["children"].append(box) made_children.add(comp.uuid) @@ -277,8 +287,14 @@ def collect(self) -> None: self.obj, interface ) if comp.uuid not in made_children: - box = makers.make_box(comp) - box["children"] = [makers.make_box(c) for c in functions] + children = [makers.make_box(c) for c in functions] + if children: + layout_options = makers.DEFAULT_LABEL_LAYOUT_OPTIONS + else: + layout_options = makers.CENTRIC_LABEL_LAYOUT_OPTIONS + + box = makers.make_box(comp, layout_options=layout_options) + box["children"] = children self.data["children"].append(box) made_children.add(comp.uuid) diff --git a/capellambse_context_diagrams/collectors/makers.py b/capellambse_context_diagrams/collectors/makers.py index 762e3946..1451e1f0 100644 --- a/capellambse_context_diagrams/collectors/makers.py +++ b/capellambse_context_diagrams/collectors/makers.py @@ -6,8 +6,9 @@ import collections.abc as cabc import typing_extensions as te -from capellambse import helpers +from capellambse import helpers as chelpers from capellambse.model import common, layers +from capellambse.svg import helpers as svghelpers from capellambse.svg.decorations import icon_padding, icon_size from .. import _elkjs, context @@ -16,14 +17,16 @@ """Default size of ports in pixels.""" PORT_PADDING = 2 """Default padding of ports in pixels.""" -LABEL_HPAD = 15 +LABEL_HPAD = 5 """Horizontal padding left and right of the label.""" LABEL_VPAD = 1 """Vertical padding above and below the label.""" NEIGHBOR_VMARGIN = 20 """Vertical space between two neighboring boxes.""" -EOI_WIDTH = 150 +EOI_WIDTH = 100 """The width of the element of interest.""" +MAX_BOX_WIDTH = 150 +"""Maximum width of boxes.""" MIN_SYMBOL_WIDTH = 30 """Minimum width of symbols.""" MIN_SYMBOL_HEIGHT = 17 @@ -54,6 +57,14 @@ "nodeLabels.placement": "INSIDE, V_TOP, H_CENTER" } """Default layout options for a label.""" +CENTRIC_LABEL_LAYOUT_OPTIONS: _elkjs.LayoutOptions = { + "nodeLabels.placement": "INSIDE, V_CENTER, H_CENTER" +} +"""Layout options for a centric label.""" +SYMBOL_LAYOUT_OPTIONS: _elkjs.LayoutOptions = { + "nodeLabels.placement": "OUTSIDE, V_BOTTOM, H_CENTER" +} +"""Layout options for a symbol label.""" def make_diagram(diagram: context.ContextDiagram) -> _elkjs.ELKInputData: @@ -68,21 +79,38 @@ def make_diagram(diagram: context.ContextDiagram) -> _elkjs.ELKInputData: def make_label( text: str, - icon: tuple[int | float, int | float] = (0, 0), + icon: tuple[int | float, int | float] = (ICON_WIDTH, ICON_HEIGHT), layout_options: _elkjs.LayoutOptions | None = None, -) -> _elkjs.ELKInputLabel: + max_width: int | float | None = None, +) -> list[_elkjs.ELKInputLabel]: """Return an [`ELKInputLabel`][capellambse_context_diagrams._elkjs.ELKInputLabel]. """ - label_width, label_height = helpers.get_text_extent(text) + label_width, label_height = chelpers.get_text_extent(text) icon_width, icon_height = icon - layout_options = layout_options or DEFAULT_LABEL_LAYOUT_OPTIONS - return { - "text": text, - "width": icon_width + label_width + 2 * LABEL_HPAD, - "height": icon_height + label_height + 2 * LABEL_VPAD, - "layoutOptions": layout_options, - } + lines = [text] + if max_width is not None and label_width > max_width: + lines, _, _ = svghelpers.check_for_horizontal_overflow( + text, + max_width, + icon_padding, + icon_width, + ) + + layout_options = layout_options or CENTRIC_LABEL_LAYOUT_OPTIONS + labels: list[_elkjs.ELKInputLabel] = [] + for line in lines: + label_width, label_height = chelpers.get_text_extent(line) + labels.append( + { + "text": line, + "width": icon_width + label_width + 2 * LABEL_HPAD, + "height": icon_height + label_height + 2 * LABEL_VPAD, + "layoutOptions": layout_options, + } + ) + icon_height *= 0 + return labels class _LabelBuilder(te.TypedDict, total=True): @@ -99,26 +127,40 @@ def make_box( width: int | float = 0, height: int | float = 0, no_symbol: bool = False, - slim_width: bool = False, + slim_width: bool = True, label_getter: cabc.Callable[ [common.GenericElement], cabc.Iterable[_LabelBuilder] ] = lambda i: [ - {"text": i.name, "icon": (0, 0), "layout_options": {}} + { + "text": i.name, + "icon": (ICON_WIDTH, 0), + "layout_options": {}, + } ], # type: ignore + max_label_width: int | float = MAX_BOX_WIDTH, + layout_options: _elkjs.LayoutOptions | None = None, ) -> _elkjs.ELKInputChild: """Return an [`ELKInputChild`][capellambse_context_diagrams._elkjs.ELKInputChild]. """ - labels = [make_label(**label) for label in label_getter(obj)] + layout_options = layout_options or CENTRIC_LABEL_LAYOUT_OPTIONS + labels: list[_elkjs.ELKInputLabel] = [] + for label_builder in label_getter(obj): + if not label_builder.get("layout_options"): + label_builder.setdefault("layout_options", {}).update( + layout_options + ) + + labels.extend(make_label(**label_builder, max_width=max_label_width)) + if not no_symbol and is_symbol(obj): if height < MIN_SYMBOL_HEIGHT: height = MIN_SYMBOL_HEIGHT elif height > MAX_SYMBOL_HEIGHT: height = MAX_SYMBOL_HEIGHT width = height * SYMBOL_RATIO - labels[0]["layoutOptions"] = { - "nodeLabels.placement": "OUTSIDE, V_BOTTOM, H_CENTER" - } + for label in labels: + label.setdefault("layoutOptions", {}).update(SYMBOL_LAYOUT_OPTIONS) else: width, height = calculate_height_and_width( labels, width=width, height=height, slim_width=slim_width diff --git a/capellambse_context_diagrams/collectors/portless.py b/capellambse_context_diagrams/collectors/portless.py index 669e0e99..a2f4c688 100644 --- a/capellambse_context_diagrams/collectors/portless.py +++ b/capellambse_context_diagrams/collectors/portless.py @@ -54,7 +54,7 @@ def collector( if not diagram.display_symbols_as_boxes and makers.is_symbol( diagram.target ): - height = max(makers.MIN_SYMBOL_HEIGHT, var_height) + height = makers.MIN_SYMBOL_HEIGHT + var_height else: height = var_height diff --git a/capellambse_context_diagrams/collectors/realization_view.py b/capellambse_context_diagrams/collectors/realization_view.py index 03c35c2b..c2290303 100644 --- a/capellambse_context_diagrams/collectors/realization_view.py +++ b/capellambse_context_diagrams/collectors/realization_view.py @@ -39,7 +39,7 @@ def collector( if not (elements := lay_to_els.get(layer)): # type: ignore[call-overload] continue - labels = [makers.make_label(layer)] + labels = makers.make_label(layer) width, height = makers.calculate_height_and_width(labels) layer_box: _elkjs.ELKInputChild = { "id": elements[0]["layer"].uuid, @@ -79,7 +79,11 @@ def collector( continue if not (owner_box := children.get(owner.uuid)): - owner_box = makers.make_box(owner, no_symbol=True) + owner_box = makers.make_box( + owner, + no_symbol=True, + layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, + ) owner_box["height"] += element_box["height"] children[owner.uuid] = owner_box layer_box["children"].append(owner_box) @@ -87,6 +91,11 @@ def collector( del layer_box["children"][index] owner_box.setdefault("children", []).append(element_box) owner_box["width"] += element_box["width"] + for label in owner_box["labels"]: + label["layoutOptions"].update( + makers.DEFAULT_LABEL_LAYOUT_OPTIONS + ) + if ( source is not None and source.owner.uuid in children diff --git a/capellambse_context_diagrams/collectors/tree_view.py b/capellambse_context_diagrams/collectors/tree_view.py index 29b8f57d..b808308b 100644 --- a/capellambse_context_diagrams/collectors/tree_view.py +++ b/capellambse_context_diagrams/collectors/tree_view.py @@ -44,7 +44,7 @@ def __contains__(self, uuid: str) -> bool: objects = self.data["children"] + self.data["edges"] # type: ignore[operator] return uuid in {obj["id"] for obj in objects} - def process_class(self, cls, params): + def process_class(self, cls: ClassInfo, params: dict[str, t.Any]): self._process_box(cls.source, cls.partition, params) if not cls.primitive and isinstance(cls.target, information.Class): @@ -58,7 +58,11 @@ def process_class(self, cls, params): 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 cls.multiplicity is None: + start, end = "1", "1" + else: + start, end = cls.multiplicity + if start != "1" or end != "1": text = f"[{start}..{end}] {text}" self.data["edges"].append( @@ -66,7 +70,7 @@ def process_class(self, cls, params): "id": edge_id, "sources": [cls.source.uuid], "targets": [cls.target.uuid], - "labels": [makers.make_label(text)], + "labels": makers.make_label(text), } ) @@ -95,7 +99,10 @@ 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) + box = makers.make_box( + obj, + layout_options=makers.DEFAULT_LABEL_LAYOUT_OPTIONS, + ) self._set_data_types_and_labels(box, obj) _set_partitioning(box, partition, params) self.data["children"].append(box) @@ -122,6 +129,9 @@ def collector( """Return the class tree data for ELK.""" assert isinstance(diagram.target, information.Class) data = generic.collector(diagram, no_symbol=True) + data["children"][0]["labels"][0].setdefault("layoutOptions", {}).update( + makers.DEFAULT_LABEL_LAYOUT_OPTIONS + ) all_associations: cabc.Iterable[ information.Association ] = diagram._model.search("Association") @@ -351,8 +361,10 @@ def _get_all_non_edge_properties( continue text = _get_property_text(prop) - label = makers.make_label(text, layout_options=layout_options) - properties.append(label) + labels = makers.make_label( + text, icon=(makers.ICON_WIDTH, 0), layout_options=layout_options + ) + properties.extend(labels) if prop.type.uuid in data_types: continue @@ -386,7 +398,11 @@ def _get_property_text(prop: information.Property) -> str: def _get_legend_labels( obj: information.datatype.Enumeration | information.Class, ) -> cabc.Iterator[makers._LabelBuilder]: - yield {"text": obj.name, "icon": (0, 0), "layout_options": {}} + yield { + "text": obj.name, + "icon": (0, 0), + "layout_options": makers.DEFAULT_LABEL_LAYOUT_OPTIONS, + } if isinstance(obj, information.datatype.Enumeration): labels = [literal.name for literal in obj.literals] elif isinstance(obj, information.Class): @@ -395,8 +411,4 @@ def _get_legend_labels( return layout_options = DATA_TYPE_LABEL_LAYOUT_OPTIONS for label in labels: - yield { - "text": label, - "icon": (0, 0), - "layout_options": layout_options, - } + yield {"text": label, "icon": (0, 0), "layout_options": layout_options} diff --git a/capellambse_context_diagrams/serializers.py b/capellambse_context_diagrams/serializers.py index 6148294d..3caee71a 100644 --- a/capellambse_context_diagrams/serializers.py +++ b/capellambse_context_diagrams/serializers.py @@ -177,18 +177,16 @@ class type that stores all previously named classes. assert parent is not None if isinstance(parent, diagram.Box) and not parent.port: if parent.JSON_TYPE != "symbol": - parent.labels.append(child["text"]) parent.styleoverrides |= self.get_styleoverrides(child) - else: - parent.labels.append( - diagram.Box( - ref - + (child["position"]["x"], child["position"]["y"]), - (child["size"]["width"], child["size"]["height"]), - labels=[child["text"]], - styleoverrides=self.get_styleoverrides(child), - ) + + parent.labels.append( + diagram.Box( + ref + (child["position"]["x"], child["position"]["y"]), + (child["size"]["width"], child["size"]["height"]), + labels=[child["text"]], + styleoverrides=self.get_styleoverrides(child), ) + ) else: assert isinstance(parent, diagram.Edge) parent.labels.append( diff --git a/tests/test_labels.py b/tests/test_labels.py index b8ea4289..472ec5ee 100644 --- a/tests/test_labels.py +++ b/tests/test_labels.py @@ -6,22 +6,27 @@ @pytest.mark.parametrize( - "uuid", + "uuid,expected_labels", [ pytest.param( - "8bcb11e6-443b-4b92-bec2-ff1d87a224e7", id="OperationalCapability" - ), - pytest.param( - "230c4621-7e0a-4d0a-9db2-d4ba5e97b3df", id="SystemComponent Root" - ), - pytest.param( - "d817767f-68b7-49a5-aa47-13419d41df0a", id="LogicalFunction" + "d817767f-68b7-49a5-aa47-13419d41df0a", + [ + ["Really long label"], + ["that needs"], + ["wrapping else"], + ["its parent box is"], + ["also very long!"], + ], + id="LogicalFunction", ), ], ) -def test_context_diagrams(model: capellambse.MelodyModel, uuid: str) -> None: +def test_context_diagrams( + model: capellambse.MelodyModel, uuid: str, expected_labels: list[list[str]] +) -> None: obj = model.by_uuid(uuid) diagram = obj.context_diagram.render(None) + labels = [label.labels for label in diagram[uuid].labels] - diagram[uuid] + assert labels == expected_labels