feat(dashboard): one-click auto-layout respecting column bands
This commit is contained in:
@@ -14,7 +14,7 @@ import {
|
||||
type Edge as FlowEdge,
|
||||
} from "@xyflow/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2 } from "lucide-react";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
FOREACH_CHILD_X,
|
||||
FOREACH_CHILD_Y,
|
||||
} from "./workflow-flow-mapping";
|
||||
import { autoLayout, applyAutoLayout } from "./workflow-auto-layout";
|
||||
import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
|
||||
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
|
||||
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
|
||||
@@ -542,6 +543,13 @@ function InnerEditor({
|
||||
[setNodes, t],
|
||||
);
|
||||
|
||||
// Auto-layout: one-click left-to-right tidy (U5, R8). Recomputes positions
|
||||
// only; bands and foreach template children are left in place. Marks the
|
||||
// editor dirty automatically via the layout serialization in isDirty.
|
||||
const handleAutoLayout = useCallback(() => {
|
||||
setNodes((ns) => applyAutoLayout(ns, autoLayout(ns, edges, columns)));
|
||||
}, [setNodes, edges, columns]);
|
||||
|
||||
const updateSelectedData = useCallback(
|
||||
(
|
||||
patch:
|
||||
@@ -1175,6 +1183,13 @@ function InnerEditor({
|
||||
))}
|
||||
</div>
|
||||
<div className="wf-editor-actions">
|
||||
<button
|
||||
className="wf-editor-action"
|
||||
onClick={handleAutoLayout}
|
||||
data-testid="wf-auto-layout"
|
||||
>
|
||||
<LayoutGrid size={13} /> {t("workflowNodes.autoLayout", "Auto-layout")}
|
||||
</button>
|
||||
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
|
||||
<Trash2 size={13} /> {t("common.delete", "Delete")}
|
||||
</button>
|
||||
|
||||
@@ -329,6 +329,47 @@ describe("WorkflowNodeEditor — U3 deletion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowNodeEditor — U5 auto-layout", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
|
||||
});
|
||||
afterEach(() => cleanup());
|
||||
|
||||
it("shows the Auto-layout button for an editable workflow", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-node-start");
|
||||
expect(screen.getByTestId("wf-auto-layout")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show the Auto-layout button for a built-in workflow", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByTestId("wf-readonly-banner");
|
||||
expect(screen.queryByTestId("wf-auto-layout")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("repositions nodes on click (a node's transform changes)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
const { container } = render(
|
||||
<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />,
|
||||
);
|
||||
await screen.findByTestId("wf-node-start");
|
||||
// React Flow positions step nodes via a translate transform on their wrapper.
|
||||
const wrapperFor = (id: string) =>
|
||||
container.querySelector<HTMLElement>(`.react-flow__node[data-id="${id}"]`);
|
||||
const before = wrapperFor("step")?.style.transform ?? "";
|
||||
fireEvent.click(screen.getByTestId("wf-auto-layout"));
|
||||
await waitFor(() => {
|
||||
const after = wrapperFor("step")?.style.transform ?? "";
|
||||
expect(after).not.toBe("");
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── U8: step-inversion authoring (foreach/step-review/parse-steps/code) ──────
|
||||
|
||||
/** A custom v2 workflow with a foreach (one step-execute child + a step-review)
|
||||
@@ -415,7 +456,9 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
|
||||
// Adding a foreach renders a group node with an empty inspector hint absent
|
||||
// (it has a child) and an inspector for the foreach.
|
||||
fireEvent.click(screen.getByText("For-each step").closest("button")!);
|
||||
await waitFor(() => expect(screen.getByTestId("wf-node-foreach")).toBeInTheDocument());
|
||||
// 3s timeout: React Flow group-node mount can exceed the 1s default under
|
||||
// cold-transform shard load (observed intermittently in CI-like runs).
|
||||
await waitFor(() => expect(screen.getByTestId("wf-node-foreach")).toBeInTheDocument(), { timeout: 3000 });
|
||||
// The foreach inspector shows the Mode select (KTD-3).
|
||||
expect(screen.getByText("Mode")).toBeInTheDocument();
|
||||
// No empty-state hint because the palette seeded a step-execute child.
|
||||
@@ -854,7 +897,11 @@ describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dir
|
||||
it("cancels an inline rename on Escape (value reverts)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
fireEvent.click(await screen.findByTestId("wf-workflow-name"));
|
||||
// Wait for the editor to fully stabilize (column panel rendered) before
|
||||
// interacting — clicking mid-load races the initial render cycle.
|
||||
await screen.findByText("Save");
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
|
||||
fireEvent.click(screen.getByTestId("wf-workflow-name"));
|
||||
const input = (await screen.findByTestId("wf-workflow-name-input")) as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "Throwaway" } });
|
||||
fireEvent.keyDown(input, { key: "Escape" });
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
|
||||
import type { WorkflowIrColumn } from "@fusion/core";
|
||||
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
|
||||
import { autoLayout, applyAutoLayout } from "../workflow-auto-layout";
|
||||
import {
|
||||
strictColumnForY,
|
||||
bandTop,
|
||||
columnBandNodeId,
|
||||
COLUMN_BAND_HEIGHT,
|
||||
} from "../workflow-flow-mapping";
|
||||
|
||||
type N = FlowNode<WorkflowFlowNodeData>;
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
kind: WorkflowFlowNodeData["kind"],
|
||||
x: number,
|
||||
y: number,
|
||||
extra: Partial<N> & { column?: string } = {},
|
||||
): N {
|
||||
const { column, ...rest } = extra;
|
||||
return {
|
||||
id,
|
||||
type: kind,
|
||||
position: { x, y },
|
||||
data: { kind, label: id, ...(column ? { column } : {}) },
|
||||
...rest,
|
||||
} as N;
|
||||
}
|
||||
|
||||
function edge(source: string, target: string, kind?: "rework"): FlowEdge {
|
||||
return {
|
||||
id: `e-${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
data: { condition: "success", kind },
|
||||
};
|
||||
}
|
||||
|
||||
const COLUMNS_3: WorkflowIrColumn[] = [
|
||||
{ id: "triage", name: "Triage", traits: [] },
|
||||
{ id: "in-progress", name: "In progress", traits: [] },
|
||||
{ id: "done", name: "Done", traits: [] },
|
||||
];
|
||||
|
||||
/** Mid-band y for a column index (a stable starting placement). */
|
||||
function midBand(index: number): number {
|
||||
return bandTop(index) + COLUMN_BAND_HEIGHT / 2;
|
||||
}
|
||||
|
||||
describe("autoLayout — v2 (column-preserving)", () => {
|
||||
it("linear chain: strictly increasing x and every node keeps its column", () => {
|
||||
const nodes: N[] = [
|
||||
node("start", "start", 999, midBand(0), { column: "triage" }),
|
||||
node("a", "prompt", 50, midBand(1), { column: "in-progress" }),
|
||||
node("b", "prompt", 10, midBand(1), { column: "in-progress" }),
|
||||
node("end", "end", 0, midBand(2), { column: "done" }),
|
||||
];
|
||||
const edges = [edge("start", "a"), edge("a", "b"), edge("b", "end")];
|
||||
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
||||
|
||||
const xs = ["start", "a", "b", "end"].map((id) => pos.get(id)!.x);
|
||||
for (let i = 1; i < xs.length; i++) {
|
||||
expect(xs[i]).toBeGreaterThan(xs[i - 1]);
|
||||
}
|
||||
|
||||
// Invariant: column unchanged for every node.
|
||||
for (const n of nodes) {
|
||||
const original = n.data.column!;
|
||||
const newY = pos.get(n.id)!.y;
|
||||
expect(strictColumnForY(newY, COLUMNS_3)).toBe(original);
|
||||
}
|
||||
});
|
||||
|
||||
it("branching graph: two branch targets get distinct positions", () => {
|
||||
const nodes: N[] = [
|
||||
node("start", "start", 0, midBand(0), { column: "triage" }),
|
||||
node("ok", "prompt", 0, midBand(1), { column: "in-progress" }),
|
||||
node("fail", "prompt", 0, midBand(1), { column: "in-progress" }),
|
||||
];
|
||||
const edges = [edge("start", "ok"), edge("start", "fail")];
|
||||
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
||||
|
||||
const a = pos.get("ok")!;
|
||||
const b = pos.get("fail")!;
|
||||
expect(a.x === b.x && a.y === b.y).toBe(false);
|
||||
// Same layer + same band → stacked vertically.
|
||||
expect(a.x).toBe(b.x);
|
||||
expect(a.y).not.toBe(b.y);
|
||||
expect(strictColumnForY(a.y, COLUMNS_3)).toBe("in-progress");
|
||||
expect(strictColumnForY(b.y, COLUMNS_3)).toBe("in-progress");
|
||||
});
|
||||
|
||||
it("dense band: more same-layer/same-band nodes than fit 220px stay in-band, staggered x, no collisions", () => {
|
||||
const count = 12;
|
||||
const nodes: N[] = [node("start", "start", 0, midBand(0), { column: "triage" })];
|
||||
const edges: FlowEdge[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
nodes.push(node(`n${i}`, "prompt", 0, midBand(1), { column: "in-progress" }));
|
||||
edges.push(edge("start", `n${i}`));
|
||||
}
|
||||
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = pos.get(`n${i}`)!;
|
||||
// All stay in their band.
|
||||
expect(strictColumnForY(p.y, COLUMNS_3)).toBe("in-progress");
|
||||
// No two nodes share a position.
|
||||
const key = `${p.x},${p.y}`;
|
||||
expect(seen.has(key)).toBe(false);
|
||||
seen.add(key);
|
||||
}
|
||||
// Overflow forced horizontal staggering (more than one distinct x).
|
||||
const distinctX = new Set([...seen].map((k) => k.split(",")[0]));
|
||||
expect(distinctX.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("derives column from y when data.column is absent", () => {
|
||||
const nodes: N[] = [
|
||||
node("start", "start", 0, midBand(0)),
|
||||
node("a", "prompt", 0, midBand(2)),
|
||||
];
|
||||
const pos = autoLayout(nodes, [edge("start", "a")], COLUMNS_3);
|
||||
expect(strictColumnForY(pos.get("start")!.y, COLUMNS_3)).toBe("triage");
|
||||
expect(strictColumnForY(pos.get("a")!.y, COLUMNS_3)).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoLayout — v1 (free placement)", () => {
|
||||
it("produces layered positions, no NaN, deterministic across two calls", () => {
|
||||
const nodes: N[] = [
|
||||
node("start", "start", 0, 0),
|
||||
node("a", "prompt", 0, 0),
|
||||
node("b", "prompt", 0, 0),
|
||||
node("end", "end", 0, 0),
|
||||
];
|
||||
const edges = [edge("start", "a"), edge("start", "b"), edge("a", "end"), edge("b", "end")];
|
||||
const p1 = autoLayout(nodes, edges, []);
|
||||
const p2 = autoLayout(nodes, edges, []);
|
||||
|
||||
for (const n of nodes) {
|
||||
const p = p1.get(n.id)!;
|
||||
expect(Number.isFinite(p.x)).toBe(true);
|
||||
expect(Number.isFinite(p.y)).toBe(true);
|
||||
}
|
||||
// start before branches before end.
|
||||
expect(p1.get("start")!.x).toBeLessThan(p1.get("a")!.x);
|
||||
expect(p1.get("a")!.x).toBeLessThan(p1.get("end")!.x);
|
||||
// Deterministic.
|
||||
for (const n of nodes) {
|
||||
expect(p1.get(n.id)).toEqual(p2.get(n.id));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoLayout — foreach / unreachable / cycles", () => {
|
||||
it("repositions a foreach group but leaves its parentId children untouched", () => {
|
||||
const childPos = { x: 30, y: 56 };
|
||||
const nodes: N[] = [
|
||||
node("start", "start", 0, midBand(0), { column: "triage" }),
|
||||
node("grp", "foreach", 999, midBand(1), { column: "in-progress" }),
|
||||
{
|
||||
id: "grp::c1",
|
||||
type: "prompt",
|
||||
position: { ...childPos },
|
||||
parentId: "grp",
|
||||
extent: "parent",
|
||||
data: { kind: "prompt", label: "c1" },
|
||||
} as N,
|
||||
];
|
||||
const edges = [edge("start", "grp")];
|
||||
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
||||
|
||||
// Group moved.
|
||||
expect(pos.has("grp")).toBe(true);
|
||||
expect(pos.get("grp")!.x).not.toBe(999);
|
||||
// Child not in the position map → untouched.
|
||||
expect(pos.has("grp::c1")).toBe(false);
|
||||
|
||||
const applied = applyAutoLayout(nodes, pos);
|
||||
const child = applied.find((n) => n.id === "grp::c1")!;
|
||||
expect(child.position).toEqual(childPos);
|
||||
});
|
||||
|
||||
it("gives an unreachable node a finite position in a trailing layer", () => {
|
||||
const nodes: N[] = [
|
||||
node("start", "start", 0, midBand(0), { column: "triage" }),
|
||||
node("a", "prompt", 0, midBand(1), { column: "in-progress" }),
|
||||
node("orphan", "prompt", 0, midBand(2), { column: "done" }),
|
||||
];
|
||||
const edges = [edge("start", "a")];
|
||||
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
||||
|
||||
const o = pos.get("orphan")!;
|
||||
expect(Number.isFinite(o.x)).toBe(true);
|
||||
expect(Number.isFinite(o.y)).toBe(true);
|
||||
// Trailing layer is past the reachable nodes.
|
||||
expect(o.x).toBeGreaterThan(pos.get("a")!.x);
|
||||
expect(strictColumnForY(o.y, COLUMNS_3)).toBe("done");
|
||||
});
|
||||
|
||||
it("terminates and stays sane with a rework cycle edge present", () => {
|
||||
const nodes: N[] = [
|
||||
node("start", "start", 0, midBand(0), { column: "triage" }),
|
||||
node("a", "prompt", 0, midBand(1), { column: "in-progress" }),
|
||||
node("b", "prompt", 0, midBand(1), { column: "in-progress" }),
|
||||
node("end", "end", 0, midBand(2), { column: "done" }),
|
||||
];
|
||||
const edges = [
|
||||
edge("start", "a"),
|
||||
edge("a", "b"),
|
||||
edge("b", "end"),
|
||||
edge("b", "a", "rework"), // rework loop — ignored for layering
|
||||
];
|
||||
const pos = autoLayout(nodes, edges, COLUMNS_3);
|
||||
|
||||
// Layering ignores rework: a strictly before b.
|
||||
expect(pos.get("a")!.x).toBeLessThan(pos.get("b")!.x);
|
||||
for (const n of nodes) {
|
||||
expect(strictColumnForY(pos.get(n.id)!.y, COLUMNS_3)).toBe(n.data.column);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores column band group nodes", () => {
|
||||
const nodes: N[] = [
|
||||
{
|
||||
id: columnBandNodeId("triage"),
|
||||
type: "group",
|
||||
position: { x: -40, y: bandTop(0) },
|
||||
data: { kind: "start", label: "Triage" },
|
||||
} as N,
|
||||
node("start", "start", 0, midBand(0), { column: "triage" }),
|
||||
];
|
||||
const pos = autoLayout(nodes, [], COLUMNS_3);
|
||||
expect(pos.has(columnBandNodeId("triage"))).toBe(false);
|
||||
expect(pos.has("start")).toBe(true);
|
||||
});
|
||||
});
|
||||
215
packages/dashboard/app/components/workflow-auto-layout.ts
Normal file
215
packages/dashboard/app/components/workflow-auto-layout.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
|
||||
import type { WorkflowIrColumn } from "@fusion/core";
|
||||
import type { WorkflowFlowNodeData } from "./nodes/WorkflowNodeTypes";
|
||||
import {
|
||||
WF_CARD_MAX_WIDTH,
|
||||
WF_CARD_HEIGHT,
|
||||
COLUMN_BAND_HEIGHT,
|
||||
bandTop,
|
||||
columnForY,
|
||||
isColumnBandNode,
|
||||
} from "./workflow-flow-mapping";
|
||||
|
||||
// ── One-click auto-layout (U5, R8) ───────────────────────────────────────────
|
||||
//
|
||||
// Pure left-to-right "tidy" that NEVER re-columns or unplaces a node. Layering
|
||||
// derives x from graph topology (longest-path from the start node, ignoring
|
||||
// rework edges and tolerating cycles); y is constrained per-node to the band of
|
||||
// the column the node already belongs to (v2) or assigned by within-layer index
|
||||
// (v1). When same-layer/same-band nodes exceed a band's vertical capacity,
|
||||
// overflow staggers horizontally (extra x offset) rather than escaping the band
|
||||
// — preserving the test-enforced invariant strictColumnForY(newY) === original
|
||||
// column for every node. Foreach group nodes move as units; their parentId
|
||||
// template children are positioned parent-relative and are left untouched.
|
||||
|
||||
/** Horizontal gap between layer columns (added to the card max-width to derive
|
||||
* the per-layer x spacing). Exported so U5's tests and any future tuning share
|
||||
* the single source of truth rather than duplicating the number. */
|
||||
export const WF_AUTO_LAYOUT_GAP_X = 80;
|
||||
|
||||
/** Vertical gap between stacked same-layer/same-band cards. */
|
||||
export const WF_AUTO_LAYOUT_GAP_Y = 24;
|
||||
|
||||
/** Padding inside a band before the first card / after the last card row. */
|
||||
export const WF_AUTO_LAYOUT_BAND_PADDING = 16;
|
||||
|
||||
/** Per-layer horizontal spacing: a full card-width plus the gap. */
|
||||
export const WF_AUTO_LAYOUT_SPACING = WF_CARD_MAX_WIDTH + WF_AUTO_LAYOUT_GAP_X;
|
||||
|
||||
/** Left/top origin for the laid-out graph. */
|
||||
const ORIGIN_X = 40;
|
||||
const ORIGIN_Y = 40;
|
||||
|
||||
/** Row height for a stacked card (card + vertical gap). */
|
||||
const ROW_HEIGHT = WF_CARD_HEIGHT + WF_AUTO_LAYOUT_GAP_Y;
|
||||
|
||||
type LayoutNode = FlowNode<WorkflowFlowNodeData>;
|
||||
|
||||
/** A node is layoutable by auto-layout when it is a top-level step node: not a
|
||||
* column band group and not a foreach template child (parentId set). Foreach
|
||||
* GROUP nodes ARE layoutable (they move as a unit). */
|
||||
function isLayoutable(node: LayoutNode): boolean {
|
||||
if (isColumnBandNode(node.id)) return false;
|
||||
if (node.parentId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign each layoutable node a layer index via longest-path layering from the
|
||||
* start node. Rework edges (data.kind === "rework") are ignored for layering.
|
||||
* Cycle-safe: a per-node depth cap plus a visited guard bounds the relaxation so
|
||||
* non-rework cycles (should not occur, but be defensive) cannot loop forever.
|
||||
* Nodes unreachable from start land in a trailing layer (max + 1).
|
||||
*/
|
||||
function layerNodes(nodeIds: string[], edges: FlowEdge[]): Map<string, number> {
|
||||
const idSet = new Set(nodeIds);
|
||||
// Adjacency over non-rework edges whose endpoints are both layoutable.
|
||||
const adj = new Map<string, string[]>();
|
||||
const indegree = new Map<string, number>();
|
||||
for (const id of nodeIds) indegree.set(id, 0);
|
||||
for (const e of edges) {
|
||||
if ((e.data?.kind as string | undefined) === "rework") continue;
|
||||
if (!idSet.has(e.source) || !idSet.has(e.target)) continue;
|
||||
if (e.source === e.target) continue;
|
||||
(adj.get(e.source) ?? adj.set(e.source, []).get(e.source)!).push(e.target);
|
||||
indegree.set(e.target, (indegree.get(e.target) ?? 0) + 1);
|
||||
}
|
||||
|
||||
// Roots: explicit start node(s), plus any node with no incoming layering edge
|
||||
// (so isolated graphs without a "start" still get laid out).
|
||||
const startIds = nodeIds.filter((id) => id === "start");
|
||||
const roots = startIds.length
|
||||
? startIds
|
||||
: nodeIds.filter((id) => (indegree.get(id) ?? 0) === 0);
|
||||
|
||||
const layer = new Map<string, number>();
|
||||
// BFS longest-path relaxation. The cap bounds work in the presence of any
|
||||
// accidental non-rework cycle: a node can be relaxed at most nodeIds.length
|
||||
// times before its layer would exceed the maximum possible acyclic depth.
|
||||
const cap = nodeIds.length + 1;
|
||||
const queue: string[] = [];
|
||||
for (const r of roots) {
|
||||
layer.set(r, 0);
|
||||
queue.push(r);
|
||||
}
|
||||
let guard = nodeIds.length * nodeIds.length + nodeIds.length + 1;
|
||||
while (queue.length && guard-- > 0) {
|
||||
const cur = queue.shift()!;
|
||||
const curLayer = layer.get(cur) ?? 0;
|
||||
for (const next of adj.get(cur) ?? []) {
|
||||
const candidate = curLayer + 1;
|
||||
const existing = layer.get(next);
|
||||
if ((existing === undefined || candidate > existing) && candidate <= cap) {
|
||||
layer.set(next, candidate);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable nodes → a trailing layer after the deepest reached layer.
|
||||
let maxLayer = 0;
|
||||
for (const v of layer.values()) if (v > maxLayer) maxLayer = v;
|
||||
const trailing = layer.size ? maxLayer + 1 : 0;
|
||||
for (const id of nodeIds) {
|
||||
if (!layer.has(id)) layer.set(id, trailing);
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
|
||||
/** Result: nodeId → new absolute position. Only positions change. */
|
||||
export type AutoLayoutPositions = Map<string, { x: number; y: number }>;
|
||||
|
||||
/**
|
||||
* Compute new positions for the layoutable nodes. Returns a Map keyed by node
|
||||
* id; nodes not in the map (band groups, foreach children) keep their current
|
||||
* positions. The caller applies the map via setNodes (positions only).
|
||||
*
|
||||
* @param nodes current flow nodes (bands + steps + foreach children)
|
||||
* @param edges current flow edges
|
||||
* @param columns the authored v2 columns (empty array ⇒ v1 free placement)
|
||||
*/
|
||||
export function autoLayout(
|
||||
nodes: LayoutNode[],
|
||||
edges: FlowEdge[],
|
||||
columns: WorkflowIrColumn[],
|
||||
): AutoLayoutPositions {
|
||||
const layoutables = nodes.filter(isLayoutable);
|
||||
const ids = layoutables.map((n) => n.id);
|
||||
const layer = layerNodes(ids, edges);
|
||||
|
||||
// Stable sort within a layer: current y, then id. Deterministic across calls.
|
||||
const byId = new Map(layoutables.map((n) => [n.id, n]));
|
||||
const positions: AutoLayoutPositions = new Map();
|
||||
|
||||
// Group node ids by layer.
|
||||
const layers = new Map<number, string[]>();
|
||||
for (const id of ids) {
|
||||
const l = layer.get(id) ?? 0;
|
||||
(layers.get(l) ?? layers.set(l, []).get(l)!).push(id);
|
||||
}
|
||||
|
||||
const v2 = columns.length > 0;
|
||||
|
||||
for (const [layerIndex, layerIds] of layers) {
|
||||
const sorted = [...layerIds].sort((a, b) => {
|
||||
const na = byId.get(a)!;
|
||||
const nb = byId.get(b)!;
|
||||
if (na.position.y !== nb.position.y) return na.position.y - nb.position.y;
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
});
|
||||
|
||||
const layerX = ORIGIN_X + layerIndex * WF_AUTO_LAYOUT_SPACING;
|
||||
|
||||
if (!v2) {
|
||||
// v1: free placement — within-layer index × row height.
|
||||
sorted.forEach((id, withinIdx) => {
|
||||
positions.set(id, { x: layerX, y: ORIGIN_Y + withinIdx * ROW_HEIGHT });
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// v2: each node KEEPS its column. Track the next free row per column so
|
||||
// same-layer/same-band nodes stack downward; overflow staggers x.
|
||||
// Per (column, stagger-bucket) we track how many rows are filled.
|
||||
const rowsPerColumn = new Map<string, number>();
|
||||
for (const id of sorted) {
|
||||
const node = byId.get(id)!;
|
||||
const colId = node.data.column ?? columnForY(node.position.y, columns);
|
||||
const colIndex = colId ? columns.findIndex((c) => c.id === colId) : -1;
|
||||
const safeColIndex = colIndex >= 0 ? colIndex : 0;
|
||||
|
||||
const top = bandTop(safeColIndex);
|
||||
const firstY = top + WF_AUTO_LAYOUT_BAND_PADDING;
|
||||
// Last y at which a card still fits fully inside the band.
|
||||
const maxY = top + COLUMN_BAND_HEIGHT - WF_CARD_HEIGHT - WF_AUTO_LAYOUT_BAND_PADDING;
|
||||
const capacity = Math.max(1, Math.floor((maxY - firstY) / ROW_HEIGHT) + 1);
|
||||
|
||||
const used = rowsPerColumn.get(colId ?? `__idx${safeColIndex}`) ?? 0;
|
||||
const rowInBucket = used % capacity;
|
||||
const staggerBucket = Math.floor(used / capacity);
|
||||
|
||||
const y = firstY + rowInBucket * ROW_HEIGHT;
|
||||
// Stagger x by half-spacing per overflow bucket so wrapped rows don't
|
||||
// collide with the un-staggered column while staying in the same band.
|
||||
const x = layerX + staggerBucket * (WF_AUTO_LAYOUT_SPACING / 2);
|
||||
|
||||
positions.set(id, { x, y });
|
||||
rowsPerColumn.set(colId ?? `__idx${safeColIndex}`, used + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
/** Apply auto-layout positions to a node list, returning a new array. Only
|
||||
* positions of mapped nodes change; everything else is preserved by reference
|
||||
* shape. Convenience for setNodes in the editor. */
|
||||
export function applyAutoLayout(
|
||||
nodes: LayoutNode[],
|
||||
positions: AutoLayoutPositions,
|
||||
): LayoutNode[] {
|
||||
return nodes.map((n) => {
|
||||
const pos = positions.get(n.id);
|
||||
return pos ? { ...n, position: pos } : n;
|
||||
});
|
||||
}
|
||||
@@ -6757,6 +6757,7 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "Advisory",
|
||||
"autoLayout": "Auto-layout",
|
||||
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
|
||||
"codeSource": "Source (TypeScript)",
|
||||
"codeTimeout": "Timeout (ms)",
|
||||
|
||||
@@ -6757,6 +6757,7 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "",
|
||||
"autoLayout": "",
|
||||
"codeNote": "Ejecuta TypeScript en un entorno aislado. La sintaxis se valida al guardar.",
|
||||
"codeSource": "Origen (TypeScript)",
|
||||
"codeTimeout": "Tiempo de espera (ms)",
|
||||
|
||||
@@ -6757,6 +6757,7 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "",
|
||||
"autoLayout": "",
|
||||
"codeNote": "Exécute du TypeScript en bac à sable. La syntaxe est validée à l’enregistrement.",
|
||||
"codeSource": "Source (TypeScript)",
|
||||
"codeTimeout": "Délai d’expiration (ms)",
|
||||
|
||||
@@ -6757,6 +6757,7 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "",
|
||||
"autoLayout": "",
|
||||
"codeNote": "샌드박스에서 TypeScript를 실행합니다. 구문은 저장 시 검증됩니다.",
|
||||
"codeSource": "소스(TypeScript)",
|
||||
"codeTimeout": "제한 시간(ms)",
|
||||
|
||||
@@ -6757,6 +6757,7 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "",
|
||||
"autoLayout": "",
|
||||
"codeNote": "在沙箱中运行 TypeScript。语法在保存时校验。",
|
||||
"codeSource": "源代码(TypeScript)",
|
||||
"codeTimeout": "超时(毫秒)",
|
||||
|
||||
@@ -6757,6 +6757,7 @@
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "",
|
||||
"autoLayout": "",
|
||||
"codeNote": "在沙箱中執行 TypeScript。語法會在儲存時驗證。",
|
||||
"codeSource": "原始碼(TypeScript)",
|
||||
"codeTimeout": "逾時(毫秒)",
|
||||
|
||||
1
packages/i18n/src/resources.d.ts
vendored
1
packages/i18n/src/resources.d.ts
vendored
@@ -6759,6 +6759,7 @@ export default interface Resources {
|
||||
},
|
||||
"workflowNodes": {
|
||||
"advisory": "Advisory",
|
||||
"autoLayout": "Auto-layout",
|
||||
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
|
||||
"codeSource": "Source (TypeScript)",
|
||||
"codeTimeout": "Timeout (ms)",
|
||||
|
||||
Reference in New Issue
Block a user