Skip to content

A group can be laid out in rows - #169

Merged
endrix merged 6 commits into
mainfrom
feat/group-rows
Nov 12, 2026
Merged

endrix merged 6 commits into
mainfrom
feat/group-rows

Conversation

@endrix

@endrix endrix commented Nov 12, 2026

Copy link
Copy Markdown
Owner

Second platform piece for the actor view of streamblocks-ide, stacked on #168 (the base of this PR is that branch; retarget to main once #168 is merged). Generic: any producer can use it.

What changes

  • Rows in a group. A member of a group may carry meta.row (a whole number from zero, the first row on top). The members of a row sit side by side in the order the producer gave them, the rows one under another. A member with no row comes after the last row. A group none of whose members carries a row is laid out as before.
  • How. The layers of a downward layout are rows and a member's row is its partition: the group gets elk.direction: DOWN, elk.partitioning.activate and elk.separateConnectedComponents: false (members with nothing between them would otherwise each be a component, packed wherever it fits). applyDirectionToChildren forced every nested layout to the right; a group in rows is now the exception.
  • The source/sink pinning pass (applyBoundaryFlowLayerConstraints, run when a diagram opens) leaves a row's members alone, since a layer constraint would contradict their row. It also no longer pins a node with no port at all: such a node is neither a source nor a sink. That second part changes where a port-less node of any product lands on a fresh layout.

Checked

  • New test/group-rows.test.ts: what the transform hands the layout, the pinning pass, and two runs of the real ELK (members of a row at one height in document order, rows in order, all inside the frame; also with no edges at all).
  • Typecheck and the neutrality gate pass. The whole suite passes locally except elk-layout-port-order.test.ts, which timed out once under load and passes on its own.
  • Rendered with scripts/render-svg from the CalPy sidecar's real actor export: a state whose four actions share a priority shows them side by side; states with a priority chain show one action per row in order; the 53-state MPEG-4 parser lays out in 1.2 s.

https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv

An edge whose end names a group's scope ends on the group's frame instead
of at one of its members: a transition into a state, where the state is a
group of what can happen in it. The transform resolves a scope id as an
end; `splitEdgesAtGroups` gives every such edge one place on the frame
(`GROUP_END`, left for edges ending there, right for edges starting there),
shared by all of them, crossing the frames around the group as any edge
does. The client keeps the arrowhead of an edge that ends there and draws
no dot.

An edge between a group and something inside it (or itself) goes nowhere
the frame does not already hold. It is taken out of the model and the group
is marked (`GROUP_LOOP`): one loop on the frame for all of them.

A group whose label is empty has no title and no header band; only a scope
with no label at all is named by its id. A scope's label now reaches the
document on the sidecar-export path, which dropped it.

Reopening a saved layout puts the place where edges end back where the
edge outside the group reaches it, there being no piece inside to go by.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
A producer puts the members of a group in rows with `meta.row` (a whole
number from zero, the first row on top). The members of a row sit side by
side in the order the producer gave them, the rows one under another; a
member with no row comes after the last.

The layers of a layout that runs downward are rows, and a member's row is
its partition: such a group is laid out with `elk.direction: DOWN`,
partitioning on, and its members as one component (on their own, members
with nothing between them would each be a component, packed wherever it
fits). The engine forces every nested layout to the right; a group in rows
is the exception.

The pass that pins sources and sinks to the outer layers when a diagram
opens leaves a row's members alone: a layer constraint would contradict
their row. It also no longer pins a node that has no port at all, which is
neither a source nor a sink.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
Reopening a saved layout: a port where edges end at a group has no member
to be level with, and the layout may put it below the members. The frame
was rebuilt around the members alone, so such a port was clamped to the new
bottom edge and its edges turned diagonal. The frame now reaches down past
every port its saved routes meet. A real lay out, save and reopen of such a
document is tested.

The selection trace took the port where edges end at a group for one more
port to pass through, with nothing behind it: the group was never on a
path. That port is the group's own; the group is on the path, and what it
holds is left as it is instead of faded.

A group that holds nothing was laid out 20 by 20, its title running over
its edges. Groups have a minimum size, on layout and on reopening.

The loop of a group keeps what its edges said: their captions and error
messages are its tooltip, and it is drawn in the error colour when one of
them is in error. Before, they went with the edges.

Where an edge starts at a group the dot stays (the line began 4 px off the
frame without it); only where edges arrive does the arrowhead replace it.
The property panel names the group an edge ends at by its title.

Tests: the loop from a group to a member and to itself, sharing on the way
out, group to group, an empty group's size, the trace through a group.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
@endrix
endrix changed the base branch from feat/frame-edge-ends to main November 12, 2026 14:49
Rows are for members that relate only to what is outside their group. With
an edge between two members the layering puts its ends in different layers,
which split a row, and keeping the producer's order within a row sent the
edge through the members between its ends. A group with such an edge now
keeps the plain layout, laid out by its edges as before.

Which groups lay out in rows is decided once the groups are nested and the
edges cut (`arrangeGroupRows`), not while each group is built. A group
nested in a rows group is a member with a row of its own (a scope's `row`),
or after the last; a member opened in place keeps its row on the frame it
becomes. Before, both landed in the top row.

A group laid out in rows says so with `GROUP_ROWS`; `GROUP_ROW` is the row
of a member only. A row past what a layout can count is no row.

The changelog entry had been merged into the middle of its neighbour.

Left for later: a short row is not left-aligned over a long one, and a
frame grows by a track for every member's leaving edge.

Claude-Session: https://claude.ai/code/session_01EToj6RJRMSSHQiqGNMU9wv
@endrix
endrix merged commit c4216de into main Nov 12, 2026
1 check passed
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.

1 participant