Skip to content
Closed
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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,30 @@ platform API version.

### Changed

- **After a drop, the routes an edge cannot keep are drawn by libavoid, with
the edges on one port as one shared path.** The layout draws the edges into
a port as a trunk; the built-in router gives every edge its own channel and
drew a comb, and libavoid did the same because every edge on a port was
started at its own staggered point. Now they start at one point and
libavoid keeps them on one path for as long as they go the same way, a
trunk with a junction. The half-nudge kink the stagger was for is taken
out after routing. libavoid is the router from now on; the built-in one
remains the fallback when libavoid is unavailable or cannot solve an edge,
the choice for a reroute of every edge in a diagram of more than 200
edges, and available throughout with `WORKFLOW_DIAGRAM_ROUTER=manhattan`.
- **The edges of a node being dragged are not drawn while it moves.** They
come back as soon as a route for the node's new place is in the model, as
a schematic editor redraws a block's nets after it is placed; before, they
trailed the pointer on their old routes and jumped at the drop. A move by
arrow key hides nothing, and a cancelled drag draws them again at once. A
node held still mid-drag keeps its edges hidden: a drag with no move for 5s
is taken as one that never finishes. A bounds pass mid-drag, which draws
the server's model in the hidden layer with the node still at its old
place, no longer draws the edges again for the rest of the drag. If no
route arrives within 1.5s of the drop the edges are drawn as they were.
- The server logs one `[dialogram perf] reroute` line per reroute it
persists: edges kept, edges routed, the router and the time taken.

- **The Java ELK server is rebuilt on ELK 0.10.0, and elkjs is pinned to
0.10.0**, so a graph lays out the same on either engine; before, the
server's ELK 0.8.1 put some graphs' nodes in another order than elkjs 0.9.3.
Expand Down
155 changes: 151 additions & 4 deletions packages/diagram-client/src/elk-live-drag-router.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { EditorContextService, IActionDispatcher, TYPES } from '@eclipse-glsp/client';
import { EditorContextService, IActionDispatcher, ModifyCSSFeedbackAction, TYPES } from '@eclipse-glsp/client';
import type { GModelElement, SEdgeImpl } from '@eclipse-glsp/client';
import { Action, IActionHandler, MoveAction } from '@eclipse-glsp/sprotty';
import { inject, injectable } from 'inversify';
import { WorkflowDiagramTypes } from '@dialogram/shared';
import { WorkflowDiagramTypes, portAnchor } from '@dialogram/shared';
import { WorkflowRerouteEdgesAvoidOverlapsOperation } from './editing-action-handlers';

type ModelIndexLike = {
Expand Down Expand Up @@ -90,6 +90,86 @@ function setIncidentEdgesForDrag(root: Readonly<GModelElement>, edgeIds: Set<str
dragIncidentEdgeIds.set(getDragKey(root), edgeIds);
}

// The edges of a node being dragged are not drawn while it moves. Their
// stored routes were drawn for where the node used to be, so drawing them
// gave a stub trailing the pointer; a live route per frame gave an edge that
// jumped at the drop when the server's route replaced it. They come back
// with the server's routes, as a schematic editor redraws a block's nets
// after a drop. Keyed by the root, as the drag session is.
const dragHiddenEdgeIds = new Map<string, Set<string>>();

/**
* Whether this edge is hidden because the node it is attached to is being
* dragged. It is hidden from the first move until its stored route reaches
* its ports again: at the drop, with the first model update that carries a
* route for the node's new place, whichever server round trip brings it, or
* at once when the drag is cancelled and the node goes back. No update is
* waited for by name, since the ChangeBounds answer, the reroute answer or
* a bounds pass may be the one that carries the route.
*
* A question, not a step: it is asked of whatever model is being drawn, and
* the hidden model a bounds pass draws mid-drag has the node where the
* server last saw it, with routes that reach its ports. Were an edge crossed
* off here, that pass would cross it off for the rest of the drag.
*/
export function isEdgeHiddenForDrag(root: Readonly<GModelElement> | undefined, edge: Readonly<GModelElement>): boolean {
if (!root) {
return false;
}
const hidden = dragHiddenEdgeIds.get(getDragKey(root));
if (!hidden?.has(String((edge as any).id))) {
return false;
}
return !routeReachesPorts(root, edge);
}

function absolutePosition(element: any): { x: number; y: number } {
let x = 0;
let y = 0;
for (let current = element; current; current = current.parent) {
x += current.position?.x ?? 0;
y += current.position?.y ?? 0;
}
return { x, y };
}

function anchorOfPort(port: any): { x: number; y: number } | undefined {
if (!port || !isPortElement(port)) {
return undefined;
}
return portAnchor({
absolute: absolutePosition(port),
size: port.size ?? port.bounds,
direction: port.args?.['cal:portDirection'] as string | undefined,
side: port.args?.['cal:portSide'] as string | undefined,
type: port.type
});
}

/** Whether the edge's stored route starts and ends where its ports are now. */
function routeReachesPorts(root: Readonly<GModelElement>, edge: Readonly<GModelElement>): boolean {
const route = (edge as any).routingPoints as Array<{ x: number; y: number }> | undefined;
if (!Array.isArray(route) || route.length < 2) {
return false;
}
const index: ModelIndexLike | undefined = (root as any)?.index;
const source = anchorOfPort(getById(index, (edge as any).sourceId));
const target = anchorOfPort(getById(index, (edge as any).targetId));
if (!source || !target) {
return false;
}
const near = (a: { x: number; y: number }, b: { x: number; y: number }): boolean =>
Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5;
return near(route[0], source) && near(route[route.length - 1], target);
}

/** How long hidden edges may wait for the server's routes before they are drawn as they were. */
const REVEAL_FALLBACK_MS = 1500;
/** A finished move with no further move this long after it is the last of its series. */
const QUIET_MS = 700;
/** A drag with no move this long is taken as one that never finishes. */
const ABANDONED_DRAG_MS = 5000;

@injectable()
export class WorkflowElkLiveDragRouter implements IActionHandler {
@inject(EditorContextService)
Expand Down Expand Up @@ -117,6 +197,10 @@ export class WorkflowElkLiveDragRouter implements IActionHandler {
/** Which path armed the pending reroute — reported to the server for timing. */
private pendingTrigger: 'finished' | 'quiet' | 'requeue' = 'quiet';

/** The moved ids the hidden edge set was computed for, so a drag computes it once. */
private hiddenForMovedIds = '';
private revealTimer: ReturnType<typeof setTimeout> | undefined;

handle(action: Action): void {
if (action.kind !== MoveAction.KIND) {
return;
Expand Down Expand Up @@ -155,14 +239,31 @@ export class WorkflowElkLiveDragRouter implements IActionHandler {
// treat brief pauses mid-drag as a drag end.
markAnyDragActive(root, 650);

// Schedule a final reroute shortly after the MoveAction stream goes quiet.
// A drag in progress hides the moved nodes' edges until the drop's
// routes arrive. A move that is finished as it arrives (an arrow key)
// hides nothing: the routes follow within the round trip, and a blink
// on every key press would be worse than the stub it replaces.
if ((action as any).finished !== true) {
// A drop's reveal still pending belongs to the drag before this one.
if (this.revealTimer) {
clearTimeout(this.revealTimer);
this.revealTimer = undefined;
}
this.hideIncidentEdges(root);
}

// Schedule a final reroute after the MoveAction stream goes quiet. While
// the pointer is down the drop brings a finished move, so for a drag this
// only catches one that never finishes (the pointer released off the
// diagram); it waits long enough that a node held still mid-drag is not
// taken for a drop, which would draw its edges again before the drop.
if (this.dragEndTimer) {
clearTimeout(this.dragEndTimer);
}
this.dragEndTimer = setTimeout(() => {
this.pendingTrigger = 'quiet';
void this.flushPendingReroute(false);
}, 700);
}, (action as any).finished === true ? QUIET_MS : ABANDONED_DRAG_MS);

// If this MoveAction marks the end of the drag, reroute right after release.
if ((action as any).finished === true) {
Expand All @@ -186,6 +287,45 @@ export class WorkflowElkLiveDragRouter implements IActionHandler {
// Preview rerouting is disabled; compute incident edges once on drag-end/quiet.
}

private hideIncidentEdges(root: Readonly<GModelElement>): void {
const movedIds = Array.from(this.pendingMovedElementIds).sort().join('\u0000');
if (movedIds === this.hiddenForMovedIds && dragHiddenEdgeIds.has(getDragKey(root))) {
return;
}
const { incidentEdgeIds } = this.collectIncidentEdgesFromMovedElements(root, this.pendingMovedElementIds, new Map());
this.hiddenForMovedIds = movedIds;
if (incidentEdgeIds.size === 0) {
return;
}
dragHiddenEdgeIds.set(getDragKey(root), incidentEdgeIds);
}

/** From the final reroute on, the hidden edges may wait this long for a route; then they are drawn as they are. */
private armReveal(): void {
if (dragHiddenEdgeIds.size === 0) {
return;
}
if (this.revealTimer) {
clearTimeout(this.revealTimer);
}
this.revealTimer = setTimeout(() => this.revealHiddenEdges(), REVEAL_FALLBACK_MS);
}

private revealHiddenEdges(): void {
if (this.revealTimer) {
clearTimeout(this.revealTimer);
this.revealTimer = undefined;
}
this.hiddenForMovedIds = '';
const wereHidden = dragHiddenEdgeIds.size > 0;
dragHiddenEdgeIds.clear();
if (wereHidden) {
// The edges are drawn at the next render; make sure there is one, as
// no model update need follow (the fallback fires on its own).
void this.actionDispatcher.dispatch(ModifyCSSFeedbackAction.create({ elements: [], add: [] }) as any).catch(() => undefined);
}
}

private collectIncidentEdgesFromMovedElements(
root: Readonly<GModelElement>,
movedElementIds: Set<string>,
Expand Down Expand Up @@ -348,6 +488,8 @@ export class WorkflowElkLiveDragRouter implements IActionHandler {
if (!preview) {
this.forceNonPreviewFlush = false;
this.lastDispatchedEdgeIds = [];
// Nothing to reroute (a cancelled drag): draw the edges as they are.
this.revealHiddenEdges();
}
return;
}
Expand All @@ -365,6 +507,9 @@ export class WorkflowElkLiveDragRouter implements IActionHandler {
}

this.dispatchInFlight = true;
if (!effectivePreview) {
this.armReveal();
}
try {
await this.actionDispatcher.dispatch(
WorkflowRerouteEdgesAvoidOverlapsOperation.create({
Expand All @@ -387,6 +532,8 @@ export class WorkflowElkLiveDragRouter implements IActionHandler {
this.lastDispatchedEdgeIds = [];
}
} catch (e) {
// The routes are not coming: draw the edges as they are.
this.revealHiddenEdges();
// Re-queue so we can retry on the next tick; avoids getting stuck with stale routes.
for (const id of edgeIds) {
this.dispatchPendingEdgeIds.add(id);
Expand Down
2 changes: 2 additions & 0 deletions packages/diagram-client/src/stock-features.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ export const workflowFeaturesModule = new ContainerModule((bind, unbind, isBound
rebind(ChangeBoundsTool).to(WorkflowChangeBoundsTool).inSingletonScope();

// Drag-time rerouting: client streams MoveAction updates; server recomputes routes (ELK fixed).
// The moved node's edges are hidden through the drag and drawn again once
// a route for the node's new place is in the model (`isEdgeHiddenForDrag`).
configureActionHandler(context, MoveAction.KIND, WorkflowElkLiveDragRouter);

// The live tier: edges routed in the webview during a drag, with the same
Expand Down
7 changes: 6 additions & 1 deletion packages/diagram-client/src/views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {
import { EDGE_OPEN_ANCHOR_X_ARG, EDGE_OPEN_ANCHOR_Y_ARG } from './edge-open-mouse-listener';
import { clientBehavior } from './profile';
import { captionIsShared, carriedTextOf } from './edge-caption-sharing';
import { isIncidentEdgeInActiveDrag } from './elk-live-drag-router';
import { isEdgeHiddenForDrag, isIncidentEdgeInActiveDrag } from './elk-live-drag-router';
import { BoundaryPortGeometry, WorkflowDiagramCss, WorkflowDiagramMetadata, WorkflowDiagramTypes, normalizePortSide } from '@dialogram/shared';
import { WorkflowDiagramConstants } from '@dialogram/shared';

Expand Down Expand Up @@ -1765,6 +1765,11 @@ export class WorkflowEdgeView extends PolylineEdgeView {
// is not visible. That shared geometry is what made this possible: the
// client router previously computed anchors differently, which is why
// this code used to bypass the router registry entirely.
// An edge of a node being dragged is not drawn until a route for the
// node's new place arrives (see `isEdgeHiddenForDrag`).
if (isEdgeHiddenForDrag(rootOfEdge(edge), edge as any)) {
return svg('g', { class: { 'workflow-edge': true, 'edge-hidden-for-drag': true } });
}
const serverRoute = (edge as any).routingPoints as { x: number; y: number }[] | undefined;
const hasServerRoute = Array.isArray(serverRoute) && serverRoute.length >= 2;
const liveRouted = isIncidentEdgeInActiveDrag(rootOfEdge(edge), String((edge as any).id));
Expand Down
Loading
Loading