Skip to content

Issue #24 - Use ELK layout to position SVG tasks + links - #30

Open
LudoBroche wants to merge 3 commits into
mainfrom
24-feature-use-elk-layout-to-position-svg-tasks
Open

Issue #24 - Use ELK layout to position SVG tasks + links#30
LudoBroche wants to merge 3 commits into
mainfrom
24-feature-use-elk-layout-to-position-svg-tasks

Conversation

@LudoBroche

@LudoBroche LudoBroche commented Aug 28, 2026

Copy link
Copy Markdown
Member

PR summary

From the graph/tasks geometry ELK representation, we now used ELK to compute the tasks' optimal positions and link routing. The computed ELK layout is used to position the tasks and draw the links.

AI Disclosure

  • Claude used to pre-review the PR
  • Claude used to give advice on the pytest coverage plan.
  • Claude used to render the svg collage of all the workflow svgs in the PR comment

@LudoBroche LudoBroche linked an issue Aug 28, 2026 that may be closed by this pull request
@LudoBroche LudoBroche changed the title Issue #24 - First implementation - tasks positioning + links display Issue #24 - Use ELK layout to position SVG tasks + links Aug 28, 2026
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.33333% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/ewoksdraw/layout/elk_link_group_builder.py 95.45% 1 Missing ⚠️
src/ewoksdraw/svg/svg_task_group.py 88.88% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

@LudoBroche
LudoBroche force-pushed the 24-feature-use-elk-layout-to-position-svg-tasks branch from 036190d to dd4db3c Compare August 28, 2026 15:44

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When applying ELK layout routing computation:

elk_graph = ELK().layout(elk_graph)

The ElkGraph is modified in place, adding metadata about positioning, links, routing ...
In this version of the code, I'm creating two types:

  1. the "uncomputed" graph : ElkGraph.
  2. the "computed" graph: LaidOutElkGraph

To have something explicit like that:

elk_graph: ElkGraph = convert_ewoks_to_elk_graph( ... )
laid_out_graph: LaidOutElkGraph = ELK().layout(elk_graph)

But the definition here is a little verbose.

Another solution is to go for a single type with NotRequired elements:

class ElkChild(TypedDict):
    x: NotRequired[float]
    y: NotRequired[float]
elk_graph: ElkGraph = convert_ewoks_to_elk_graph( ... )
laid_out_graph: elk_graph = ELK().layout(elk_graph)

What do you think ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the two definitions. Sure it is more verbose but it is very clear in terms with what we expect. Since we are enforcing typing with mypy, I think it will help in the long run.

@LudoBroche LudoBroche Aug 28, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The __init__.py is getting crowded with various SVG group builders.
I'm moving these helper functions to ./layout/.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll even suggest to move everything from __init__.py in another PR. Personally, I am so used to have nothing in these files but I don't always have to idea to check it.

Moving functions in a new module also creates the opportunity to describe a bit more what the functions are for, though the module name.

if len(points) < 2:
continue

cubic_bezier_path = CubicBezierPath.from_points(

@LudoBroche LudoBroche Aug 28, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the mathematical representation of the Bezier Path.
We make the orthogonal control points into smooth curved curves.

Comment on lines +16 to +19
@pytest.fixture(params=graph_names(), ids=graph_names())
def ewoks_graph(request: pytest.FixtureRequest) -> TaskGraph:
graph_description, _ = get_graph(request.param)
return load_graph(graph_description)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So far I was only testing on a single graph.
Why not test all of them?

Comment thread src/ewoksdraw/__init__.py
task_group.extract_input_positions(),
task_group.extract_output_positions(),
)
laid_out_graph: LaidOutElkGraph = ELK().layout(elk_graph)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The layout routing is happening here.

Comment thread src/ewoksdraw/__init__.py
Comment on lines +25 to +26
task_positions = extract_task_positions_from_elk_graph(laid_out_graph)
task_group.set_task_positions(task_positions)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to keep the ELK graph representation separated from the SVG structure.

@LudoBroche

Copy link
Copy Markdown
Member Author

Tested with ewoks examples:

for graph in acyclic1 acyclic2 acyclic3 cyclic1 demo empty self_trigger triangle1
do
    pixi run graph-to-svg --test "$graph"
done

Seems to be working nicely.

all_workflows

@LudoBroche LudoBroche added this to the ewoksdraw 1.0 release milestone Aug 28, 2026
@LudoBroche
LudoBroche requested a review from loichuder August 28, 2026 17:01
Comment on lines +26 to +42
class ElkChildBase(TypedDict):
id: str
width: float
height: float
layoutOptions: dict[str, Any]
ports: list[ElkPort]


class ElkEdge(TypedDict):
class ElkChild(ElkChildBase):
"""An ELK child before layout."""


class LaidOutElkChild(ElkChildBase):
"""An ELK child with coordinates computed by ELK."""

x: float
y: float

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two comments:

  1. If the base is the same as one of the class, I don't think we need to keep both.
  2. Since the first-class citizen is the laid-out graph, I would propose to rename elements before layout to ElementBeforeLayout or UnpositionnedElement and keep the "proper" name for the laid out element Element.

Here how it would pan out for ElkChild:

Suggested change
class ElkChildBase(TypedDict):
id: str
width: float
height: float
layoutOptions: dict[str, Any]
ports: list[ElkPort]
class ElkEdge(TypedDict):
class ElkChild(ElkChildBase):
"""An ELK child before layout."""
class LaidOutElkChild(ElkChildBase):
"""An ELK child with coordinates computed by ELK."""
x: float
y: float
class ElkChildBeforeLayout(TypedDict):
id: str
width: float
height: float
layoutOptions: dict[str, Any]
ports: list[ElkPort]
class ElkChild(ElkChildBeforeLayout):
"""An ELK child with coordinates computed by ELK."""
x: float
y: float



class ElkGraph(ElkGraphBase):
"""An ELK graph before layout."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest to separate the types in two parts: one for raw, unpositionned elements and the other for laid out elements.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the two definitions. Sure it is more verbose but it is very clear in terms with what we expect. Since we are enforcing typing with mypy, I think it will help in the long run.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll even suggest to move everything from __init__.py in another PR. Personally, I am so used to have nothing in these files but I don't always have to idea to check it.

Moving functions in a new module also creates the opportunity to describe a bit more what the functions are for, though the module name.

@@ -0,0 +1,56 @@
from ewoksdraw.config.constants import LINK_TURN_RADIUS

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
from ewoksdraw.config.constants import LINK_TURN_RADIUS
from .config.constants import LINK_TURN_RADIUS

Comment on lines +20 to +21
if len(points) < 2:
continue

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By design, points length is always >=2 no?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this is the best place nor the best name for this module.

Should it not be in svg ? Or even put the function in svg_task_group?

from .svg_task import TaskSize

TaskSizes = dict[str, TaskSize]
TaskPositions = list[TaskPosition]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why about making this a dict[str, TaskPosition] ? Then, we ensure by design that the name is used as id.

Comment on lines +31 to +32
transform = group.get("transform")
assert transform is not None

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If transform is needed, we may as well let the KeyError be raised:

Suggested change
transform = group.get("transform")
assert transform is not None
transform = group["transform"]

Comment on lines +75 to +78
# Going through the source/target couples in ewoks graph edges
# checking for each the computed ELK positions
# gives source on the left of target
assert positions[source][0] < positions[target][0]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already part of the docstring

Suggested change
# Going through the source/target couples in ewoks graph edges
# checking for each the computed ELK positions
# gives source on the left of target
assert positions[source][0] < positions[target][0]
assert positions[source][0] < positions[target][0]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Use ELK layout to position SVG tasks

2 participants