Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ export class WorkflowLayoutBoundaryFlowOperationHandler extends OperationHandler
return new GModelRecordingCommand(this.modelState, this.serializer, () => this.executeOperation(operation));
}

/** The whole layout, for a handler that sets the graph's options first (see layout-tuned-handler.ts). */
runLayout(operation: WorkflowLayoutBoundaryFlowOperation): Promise<void> {
return this.executeOperation(operation);
}

private async executeOperation(operation: WorkflowLayoutBoundaryFlowOperation): Promise<void> {
const selectedMovableNodeIds = this.getSelectedMovableNodeIds(operation.elementIds);
const hasSelection = selectedMovableNodeIds.size > 0;
Expand Down
84 changes: 84 additions & 0 deletions packages/diagram-server/src/operations/layout-tuned-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { Operation } from '@eclipse-glsp/protocol';
import { GModelRecordingCommand, GModelSerializer, ModelState, OperationHandler, type Command, type MaybePromise } from '@eclipse-glsp/server';
import { inject, injectable } from 'inversify';
import { PORT_ORDER_FREE, PORT_ORDER_OPTION } from '../server/elk-layout-engine';
import { WORKFLOW_LAYOUT_BOUNDARY_FLOW_OPERATION_KIND, WorkflowLayoutBoundaryFlowOperationHandler } from './layout-boundary-flow-handler';

/**
* Two layouts beside Layout Diagram, measured on the speculation flow of
* chips/workflow (40 nodes, 106 edges; test/elk-layout-port-order.test.ts):
*
* - Untangled: the layered layout with ELK's hierarchical two-sided greedy
* switch and Brandes-Koepf placement. The ports keep the order the source
* declares them in. 278 crossings become 228.
* - Free Ports: the same, and each node's ports in the order a probe layout
* found best against crossings (see WorkflowElkLayoutEngine). 127 crossings,
* at the price of the declared order.
*
* Both run the boundary-flow layout with the graph's options set for the run
* and restored after it, so Layout Diagram itself is unchanged.
*/
export const UNTANGLED_ELK_OPTIONS: Record<string, string> = {
'elk.layered.crossingMinimization.greedySwitchHierarchical.type': 'TWO_SIDED',
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF'
};

export const WORKFLOW_LAYOUT_UNTANGLED_OPERATION_KIND = 'dialogram.layoutUntangled' as const;
export const WORKFLOW_LAYOUT_FREE_PORTS_OPERATION_KIND = 'dialogram.layoutFreePorts' as const;

export interface WorkflowLayoutTunedOperation extends Operation {
kind: typeof WORKFLOW_LAYOUT_UNTANGLED_OPERATION_KIND | typeof WORKFLOW_LAYOUT_FREE_PORTS_OPERATION_KIND;
elementIds?: string[];
}

@injectable()
abstract class WorkflowLayoutTunedOperationHandler extends OperationHandler {
@inject(ModelState)
protected override modelState!: ModelState;

@inject(GModelSerializer)
protected serializer!: GModelSerializer;

@inject(WorkflowLayoutBoundaryFlowOperationHandler)
protected boundaryFlow!: WorkflowLayoutBoundaryFlowOperationHandler;

/** The graph options this layout adds for its run. */
protected abstract graphOptions(): Record<string, string>;

override createCommand(operation: WorkflowLayoutTunedOperation): MaybePromise<Command | undefined> {
return new GModelRecordingCommand(this.modelState, this.serializer, () => this.executeOperation(operation));
}

private async executeOperation(operation: WorkflowLayoutTunedOperation): Promise<void> {
const root = this.modelState.root as any;
const previous = root.layoutOptions ? { ...(root.layoutOptions as Record<string, unknown>) } : undefined;
root.layoutOptions = { ...(previous ?? {}), ...this.graphOptions() };
try {
await this.boundaryFlow.runLayout({
kind: WORKFLOW_LAYOUT_BOUNDARY_FLOW_OPERATION_KIND,
isOperation: true,
elementIds: operation.elementIds
});
} finally {
root.layoutOptions = previous;
}
}
}

@injectable()
export class WorkflowLayoutUntangledOperationHandler extends WorkflowLayoutTunedOperationHandler {
override readonly operationType = WORKFLOW_LAYOUT_UNTANGLED_OPERATION_KIND;

protected graphOptions(): Record<string, string> {
return { ...UNTANGLED_ELK_OPTIONS };
}
}

@injectable()
export class WorkflowLayoutFreePortsOperationHandler extends WorkflowLayoutTunedOperationHandler {
override readonly operationType = WORKFLOW_LAYOUT_FREE_PORTS_OPERATION_KIND;

protected graphOptions(): Record<string, string> {
return { ...UNTANGLED_ELK_OPTIONS, [PORT_ORDER_OPTION]: PORT_ORDER_FREE };
}
}
28 changes: 28 additions & 0 deletions packages/diagram-server/src/server/context-menu-item-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ const RESET_EDGE_ROUTES_KIND = 'dialogram.resetEdgeRoutes';
const REROUTE_EDGES_AVOID_OVERLAPS_KIND = 'dialogram.rerouteEdgesAvoidOverlaps';
const LAYOUT_BOUNDARY_FLOW_KIND = 'dialogram.layoutBoundaryFlow';
const LAYOUT_SERPENTINE_MESH_KIND = 'dialogram.layoutSerpentineMesh';
const LAYOUT_UNTANGLED_KIND = 'dialogram.layoutUntangled';
const LAYOUT_FREE_PORTS_KIND = 'dialogram.layoutFreePorts';
const WORKFLOW_SOURCE_URI_ARG = 'wf:workflowSourceUri';
const WORKFLOW_SOURCE_RANGE_ARG = 'wf:workflowSourceRange';

Expand Down Expand Up @@ -512,6 +514,32 @@ export class WorkflowContextMenuItemProvider extends ContextMenuItemProvider {
]
});

items.push({
id: 'dialogram.layoutDiagramUntangled',
label: 'Untangled Layout',
sortString: 'g2.4',
group: 'layout',
actions: [
{
kind: LAYOUT_UNTANGLED_KIND,
isOperation: true
} as any
]
});

items.push({
id: 'dialogram.layoutDiagramFreePorts',
label: 'Free Ports Layout',
sortString: 'g2.5',
group: 'layout',
actions: [
{
kind: LAYOUT_FREE_PORTS_KIND,
isOperation: true
} as any
]
});

const workflowDefinitionTarget = workflowDefinitionTargetFromRoot(element, sourceUri);
if (workflowDefinitionTarget) {
items.push({
Expand Down
3 changes: 3 additions & 0 deletions packages/diagram-server/src/server/diagram-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { WorkflowResetEdgeRoutesOperationHandler } from '../operations/reset-edg
import { WorkflowRerouteEdgesAvoidOverlapsOperationHandler } from '../operations/reroute-edges-avoid-overlaps-handler';
import { WorkflowLayoutBoundaryFlowOperationHandler } from '../operations/layout-boundary-flow-handler';
import { WorkflowLayoutSerpentineMeshOperationHandler } from '../operations/layout-serpentine-mesh-handler';
import { WorkflowLayoutFreePortsOperationHandler, WorkflowLayoutUntangledOperationHandler } from '../operations/layout-tuned-handler';
import { WorkflowChangeRoutingPointsOperationHandler } from '../operations/change-routing-points-handler';
import { isDiagramOperationModule } from './operation-modules';

Expand Down Expand Up @@ -163,6 +164,8 @@ export class WorkflowDiagramModule extends GModelDiagramModule {
// Routing utilities
binding.add(WorkflowLayoutBoundaryFlowOperationHandler);
binding.add(WorkflowLayoutSerpentineMeshOperationHandler);
binding.add(WorkflowLayoutUntangledOperationHandler);
binding.add(WorkflowLayoutFreePortsOperationHandler);
binding.add(WorkflowResetEdgeRoutesOperationHandler);
binding.add(WorkflowRerouteEdgesAvoidOverlapsOperationHandler);

Expand Down
137 changes: 125 additions & 12 deletions packages/diagram-server/src/server/elk-layout-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import * as os from 'node:os';
import * as path from 'node:path';
import { mkdir, writeFile } from 'node:fs/promises';

/** Graph layout option: `free` lets a layout choose each node's port order against crossings. */
export const PORT_ORDER_OPTION = 'dialogram.portOrder';
export const PORT_ORDER_FREE = 'free';

/**
* Minimal CAL extension of the standard GLSP ELK layout engine.
*
Expand Down Expand Up @@ -52,6 +56,9 @@ export class WorkflowElkLayoutEngine extends GlspElkLayoutEngine {
}
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(input)) {
if (k.startsWith('dialogram.')) {
continue; // the server's own options, see PORT_ORDER_OPTION
}
if (typeof v === 'string') {
out[k] = v;
} else if (typeof v === 'number' || typeof v === 'boolean') {
Expand All @@ -78,23 +85,129 @@ export class WorkflowElkLayoutEngine extends GlspElkLayoutEngine {
if (!(root instanceof GGraph)) {
return root;
}
const run = async (): Promise<GGraph> => {
// Ports sit on a fixed grid in the order the source declares them, and
// ELK may not move them (FIXED_POS, see applyShape). On a dense graph that
// order is what tangles the edges: an input declared first whose producer
// is laid out last crosses every edge between. The Free Ports layout asks
// for the order to be chosen first, by a probe layout in which ELK is free
// to arrange each side's ports against crossings; the ports are then put
// back on their grid in that order, and the real layout runs as before.
if (this.portOrderingEnabled(root)) {
try {
await this.orderPortsAgainstCrossings(root);
} catch (e) {
console.warn('[cal-elk] port ordering skipped:', e);
}
}

// Re-implement base `layout` so we can dump the exact object passed to elk.layout().
(this as any).elkEdges = [];
(this as any).idToElkElement = new Map();
const elkGraph = (this as any).transformToElk(root) as ElkNode;
// Re-implement base `layout` so we can dump the exact object passed to elk.layout().
(this as any).elkEdges = [];
(this as any).idToElkElement = new Map();
const elkGraph = (this as any).transformToElk(root) as ElkNode;

const shouldDump = process.env.WORKFLOW_DIAGRAM_DUMP_ELK_INPUT_JSON === '1';
const always = process.env.WORKFLOW_DIAGRAM_DUMP_ELK_INPUT_JSON_ALWAYS === '1';
if (shouldDump && (always || !this.elkInputDumped)) {
this.elkInputDumped = true;
void this.dumpElkInputGraph(elkGraph);
}
const shouldDump = process.env.WORKFLOW_DIAGRAM_DUMP_ELK_INPUT_JSON === '1';
const always = process.env.WORKFLOW_DIAGRAM_DUMP_ELK_INPUT_JSON_ALWAYS === '1';
if (shouldDump && (always || !this.elkInputDumped)) {
this.elkInputDumped = true;
void this.dumpElkInputGraph(elkGraph);
}

return (this as any).elk.layout(elkGraph).then((result: ElkNode) => {
const result = (await (this as any).elk.layout(elkGraph)) as ElkNode;
this.applyLayout(result);
return root;
});
};
return run();
}

/**
* The pass runs only when a layout asks for it with the graph option
* `dialogram.portOrder: free` (the Free Ports layout), and only for a layered
* layout: by default, and after a drag, the ports keep the order the source
* declares them in.
*/
private portOrderingEnabled(root: GGraph): boolean {
const options = (root as any).layoutOptions as Record<string, unknown> | undefined;
if (options?.[PORT_ORDER_OPTION] !== PORT_ORDER_FREE) {
return false;
}
const algorithm = options?.['elk.algorithm'];
return algorithm === undefined || algorithm === 'layered';
}

/**
* A node whose ports may change order: every ordinary node. The if and loop
* containers place their branch ports by index, and a boundary node has one.
*/
private portsMayReorder(node: GNode): boolean {
return node.type !== WorkflowDiagramTypes.NODE_STRUCTURE_IF
&& node.type !== WorkflowDiagramTypes.NODE_STRUCTURE_IF_BRANCH
&& node.type !== WorkflowDiagramTypes.NODE_STRUCTURE_FOREACH
&& node.type !== WorkflowDiagramTypes.NODE_BOUNDARY_INPUT
&& node.type !== WorkflowDiagramTypes.NODE_BOUNDARY_OUTPUT;
}

/**
* The probe: the same graph with `FIXED_SIDE` on every ordinary node, so that
* ELK's crossing minimisation orders the ports of each side. Nothing of its
* result is kept but that order, which `regridPorts` writes back onto the
* server's rows.
*/
private async orderPortsAgainstCrossings(root: GGraph): Promise<void> {
(this as any).elkEdges = [];
(this as any).idToElkElement = new Map();
const probe = (this as any).transformToElk(root) as ElkNode;
const free = (n: ElkNode): void => {
for (const child of n.children ?? []) {
const el = this.modelState.index.get(child.id);
if (el instanceof GNode && this.portsMayReorder(el)) {
child.layoutOptions = { ...(child.layoutOptions ?? {}), 'elk.portConstraints': 'FIXED_SIDE' };
for (const p of child.ports ?? []) {
if (p.layoutOptions && 'elk.port.index' in p.layoutOptions) {
const { ['elk.port.index']: _index, ...rest } = p.layoutOptions as Record<string, string>;
p.layoutOptions = rest;
}
}
}
free(child);
}
};
free(probe);
const result = (await (this as any).elk.layout(probe)) as ElkNode;
const apply = (n: ElkNode): void => {
for (const child of n.children ?? []) {
const el = this.modelState.index.get(child.id);
if (el instanceof GNode && this.portsMayReorder(el) && child.ports && child.ports.length > 1) {
this.regridPorts(el, child.ports);
}
apply(child);
}
};
apply(result);
}

/**
* The node's ports of one side take the rows they already occupy, in the
* order ELK placed them top to bottom. The labels are the ports' children and
* move with them; nothing else about the node changes.
*/
private regridPorts(node: GNode, elkPorts: ElkPort[]): void {
for (const input of [true, false]) {
const ports = this.findChildren(node, GPort).filter(p => (p.type === WorkflowDiagramTypes.PORT_INPUT) === input);
if (ports.length < 2) {
continue;
}
const rows = ports.map(p => p.position?.y ?? 0).sort((a, b) => a - b);
const byId = new Map(ports.map(p => [p.id, p] as const));
const order = elkPorts.filter(ep => byId.has(ep.id)).sort((a, b) => (a.y ?? 0) - (b.y ?? 0));
if (order.length !== ports.length) {
continue;
}
order.forEach((ep, k) => {
const port = byId.get(ep.id)!;
port.position = { x: port.position?.x ?? 0, y: rows[k] };
});
}
}

private async dumpElkInputGraph(elkGraph: ElkNode): Promise<void> {
Expand Down
6 changes: 6 additions & 0 deletions packages/diagram-server/src/server/server-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { LayoutPersistenceService } from '../services/layout-persistence-service
import { AgentStructuralEditSignal } from './agent-structural-edit-signal';
import { AgentCreateNodeOutcomeSink } from './agent-create-node-outcome';
import { WorkflowRerouteEdgesAvoidOverlapsOperationHandler } from '../operations/reroute-edges-avoid-overlaps-handler';
import { WorkflowLayoutBoundaryFlowOperationHandler } from '../operations/layout-boundary-flow-handler';
import { DIAGRAM_MODEL_SOURCE } from './model-source-token';
import { STORAGE_RUNTIME_OPTIONS, type StorageRuntimeOptions } from './storage-runtime-options';
import { WorkflowServerLogger } from './disposed-request-logger';
Expand Down Expand Up @@ -166,6 +167,11 @@ export function createWorkflowServerModules(
if (!isBound(WorkflowRerouteEdgesAvoidOverlapsOperationHandler)) {
bind(WorkflowRerouteEdgesAvoidOverlapsOperationHandler).toSelf().inSingletonScope();
}
// The Untangled and Free Ports layouts run the boundary-flow layout with the
// graph's options set (layout-tuned-handler.ts), so they inject it by class.
if (!isBound(WorkflowLayoutBoundaryFlowOperationHandler)) {
bind(WorkflowLayoutBoundaryFlowOperationHandler).toSelf().inSingletonScope();
}
});
additionalModules.push(operationHandlerSelfModule);

Expand Down
Loading
Loading