fix: anordnen knopf wieder hinzugefügt mit verschachtelten rangpfaden

This commit is contained in:
Ida 2026-05-13 16:16:41 +02:00
parent 600e0c87dc
commit e3c93dc220
3 changed files with 338 additions and 16 deletions

View file

@ -837,6 +837,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onDeleteSelection: () => flowCanvasRef.current?.deleteSelection(), onDeleteSelection: () => flowCanvasRef.current?.deleteSelection(),
onDuplicateNode: () => flowCanvasRef.current?.duplicateSingleSelection(), onDuplicateNode: () => flowCanvasRef.current?.duplicateSingleSelection(),
onToggleConnectionTool: () => flowCanvasRef.current?.toggleConnectionTool(), onToggleConnectionTool: () => flowCanvasRef.current?.toggleConnectionTool(),
onArrangeNodes: () => flowCanvasRef.current?.arrangeNodes(),
onAddCanvasComment: () => flowCanvasRef.current?.addCanvasComment(), onAddCanvasComment: () => flowCanvasRef.current?.addCanvasComment(),
}), }),
[ [

View file

@ -25,6 +25,7 @@ import {
HiOutlineDocumentDuplicate, HiOutlineDocumentDuplicate,
HiOutlineArrowLongRight, HiOutlineArrowLongRight,
HiOutlineChatBubbleLeftEllipsis, HiOutlineChatBubbleLeftEllipsis,
HiOutlineSquares2X2,
} from 'react-icons/hi2'; } from 'react-icons/hi2';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi'; import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
@ -55,6 +56,8 @@ export interface CanvasHeaderCanvasEditProps {
onToggleConnectionTool: () => void; onToggleConnectionTool: () => void;
/** Textnotiz auf die Canvas legen (ohne Workflow-Daten). */ /** Textnotiz auf die Canvas legen (ohne Workflow-Daten). */
onAddCanvasComment: () => void; onAddCanvasComment: () => void;
/** Verschachtelte Rasterpfade (4.1 / 4.2 …); Haftnotizen unberührt. */
onArrangeNodes: () => void;
} }
interface CanvasHeaderProps { interface CanvasHeaderProps {
@ -525,6 +528,16 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
> >
<HiOutlineArrowLongRight size={18} strokeWidth={2} aria-hidden /> <HiOutlineArrowLongRight size={18} strokeWidth={2} aria-hidden />
</button> </button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!hasNodes}
onClick={canvasEdit.onArrangeNodes}
title={t('Knoten im Raster anordnen')}
aria-label={t('Knoten im Raster anordnen')}
>
<HiOutlineSquares2X2 size={18} strokeWidth={2} aria-hidden />
</button>
<button <button
type="button" type="button"
className={styles.canvasHeaderGhostIconBtn} className={styles.canvasHeaderGhostIconBtn}

View file

@ -139,21 +139,232 @@ export type FlowCanvasHandle = {
toggleConnectionTool: () => void; toggleConnectionTool: () => void;
/** Fügt eine bearbeitbare Textnotiz in der Mitte der sichtbaren Canvas ein. */ /** Fügt eine bearbeitbare Textnotiz in der Mitte der sichtbaren Canvas ein. */
addCanvasComment: () => void; addCanvasComment: () => void;
/** Raster-Anordnung: verschachtelte Rangpfade (4.1 / 4.2 …); Haftnotizen unberührt. */
arrangeNodes: () => void;
}; };
const HANDLE_SIZE = 12; const HANDLE_SIZE = 12;
const HANDLE_OFFSET = HANDLE_SIZE / 2; const HANDLE_OFFSET = HANDLE_SIZE / 2;
const LAYOUT_V_GAP = 80; const LAYOUT_V_GAP = 80;
const LAYOUT_H_GAP = 60; const LAYOUT_H_GAP = 60;
/** Kanten-Rücklauf visuell links um die Knoten zur Loop oben. */
function isLoopFeedbackEdge(c: CanvasConnection, srcNode: CanvasNode, tgtNode: CanvasNode): boolean {
if (tgtNode.type !== 'flow.loop' || c.targetHandle !== 0) return false;
if (c.sourceId === c.targetId) return true;
return srcNode.y > tgtNode.y + 4;
}
/** Für Layout-Schichtung: Graph ohne Loop-Rückkopplung (Sonst Pflicht-Schichtung senkrecht). */
function stripLoopFeedbackConnections(nodes: CanvasNode[], connections: CanvasConnection[]): CanvasConnection[] {
const byId = new Map(nodes.map((n) => [n.id, n]));
return connections.filter((c) => {
const src = byId.get(c.sourceId);
const tgt = byId.get(c.targetId);
if (!src || !tgt) return false;
return !isLoopFeedbackEdge(c, src, tgt);
});
}
/** Reihenfolge links→rechts bei Kanten nur zwischen Knoten **dieser** Zeile (DAG/Kahn). */
function orderRowByIntraRowTopo(row: CanvasNode[], connections: CanvasConnection[]): CanvasNode[] {
const idSet = new Set(row.map((n) => n.id));
const nodeById = new Map(row.map((n) => [n.id, n]));
const inDeg = new Map<string, number>();
const outs = new Map<string, string[]>();
for (const id of idSet) {
inDeg.set(id, 0);
outs.set(id, []);
}
for (const c of connections) {
if (!idSet.has(c.sourceId) || !idSet.has(c.targetId)) continue;
inDeg.set(c.targetId, (inDeg.get(c.targetId) ?? 0) + 1);
outs.get(c.sourceId)!.push(c.targetId);
}
const ready = row
.filter((n) => (inDeg.get(n.id) ?? 0) === 0)
.sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)));
const result: CanvasNode[] = [];
const q = [...ready];
while (q.length > 0) {
q.sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)));
const n = q.shift()!;
result.push(n);
for (const t of outs.get(n.id) ?? []) {
if (!idSet.has(t)) continue;
inDeg.set(t, (inDeg.get(t) ?? 1) - 1);
if (inDeg.get(t) === 0) q.push(nodeById.get(t)!);
}
}
const placed = new Set(result.map((r) => r.id));
const rest = row.filter((n) => !placed.has(n.id)).sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)));
return [...result, ...rest];
}
/** Verschachtelte Rasterposition ``[4,1]``, ``[4,2]``, ``[4,1,1]`` … für Zeilen vs. Spalten. */
function linearNestedChildPath(p: number[]): number[] {
if (p.length === 1) return [p[0] + 1];
return [Math.max(...p) + 1];
}
/** Zweig unter einem linearen Knoten ``[n]`` → ``[n+1,k]``, unter verschachteltem Pfad → ``[…p,k]``. */
function branchNestedChildPath(p: number[], branchIndex: number): number[] {
if (p.length === 1) return [p[0] + 1, branchIndex];
return [...p, branchIndex];
}
function longestPathDepthDown(
nodeId: string,
outgoingTargets: Map<string, string[]>,
memo: Map<string, number>,
visiting: Set<string>,
): number {
if (memo.has(nodeId)) return memo.get(nodeId)!;
if (visiting.has(nodeId)) return 0;
visiting.add(nodeId);
const outs = outgoingTargets.get(nodeId) ?? [];
let best = 0;
for (const t of outs) {
best = Math.max(best, longestPathDepthDown(t, outgoingTargets, memo, visiting));
}
visiting.delete(nodeId);
const d = outs.length === 0 ? 0 : 1 + best;
memo.set(nodeId, d);
return d;
}
function reachableForwardTargets(start: string, outgoingTargets: Map<string, string[]>): Set<string> {
const seen = new Set<string>();
const stack = [start];
while (stack.length > 0) {
const v = stack.pop()!;
if (seen.has(v)) continue;
seen.add(v);
for (const t of outgoingTargets.get(v) ?? []) stack.push(t);
}
return seen;
}
/** Primärbaum: längster Pfad zuerst; Kurzschlüsse (z.B. Trigger→Schleife wenn Trigger→Upload→Schleife) werden nicht verdoppelt. */
function layoutTreeChildren(
nodeId: string,
outgoingTargets: Map<string, string[]>,
depthMemo: Map<string, number>,
): string[] {
const outs = outgoingTargets.get(nodeId) ?? [];
if (outs.length <= 1) return outs;
const scored = outs.map((t) => ({
t,
d: longestPathDepthDown(t, outgoingTargets, depthMemo, new Set()),
}));
scored.sort((a, b) => b.d - a.d || a.t.localeCompare(b.t));
const primary = scored[0].t;
const reach = reachableForwardTargets(primary, outgoingTargets);
const branchOnly = outs.filter((t) => t !== primary && !reach.has(t));
branchOnly.sort((a, b) => a.localeCompare(b));
return [primary, ...branchOnly];
}
function assignNestedRankPaths(nodes: CanvasNode[], stripped: CanvasConnection[]): Map<string, number[]> {
const nodeIds = new Set(nodes.map((n) => n.id));
const outgoingTargets = new Map<string, string[]>();
for (const n of nodes) outgoingTargets.set(n.id, []);
const sortedStripped = [...stripped].sort(
(a, b) =>
a.sourceId.localeCompare(b.sourceId) ||
a.sourceHandle - b.sourceHandle ||
a.targetId.localeCompare(b.targetId),
);
for (const c of sortedStripped) {
if (!nodeIds.has(c.sourceId) || !nodeIds.has(c.targetId)) continue;
const arr = outgoingTargets.get(c.sourceId)!;
if (!arr.includes(c.targetId)) arr.push(c.targetId);
}
const ranks = new Map<string, number[]>();
const depthMemo = new Map<string, number>();
let nextFallback = 1;
function dfs(nodeId: string, path: number[]): void {
if (ranks.has(nodeId)) return;
ranks.set(nodeId, path);
const children = layoutTreeChildren(nodeId, outgoingTargets, depthMemo);
if (children.length === 0) return;
if (children.length === 1) {
dfs(children[0], linearNestedChildPath(path));
return;
}
children.forEach((ch, idx) => {
dfs(ch, branchNestedChildPath(path, idx + 1));
});
}
const roots = nodes.filter((n) => !stripped.some((c) => c.targetId === n.id));
roots.sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)));
if (roots.length === 0) {
const first = [...nodes].sort((a, b) => (a.x !== b.x ? a.x - b.x : a.id.localeCompare(b.id)))[0];
if (first) dfs(first.id, [1]);
} else {
let seq = 1;
for (const r of roots) dfs(r.id, [seq++]);
}
for (const n of nodes) {
if (!ranks.has(n.id)) dfs(n.id, [nextFallback++]);
}
return ranks;
}
function nestedRankRowGroupKey(path: number[]): string {
if (path.length <= 1) return `|L|${path.join('.')}`;
return `|B|${path.slice(0, -1).join('.')}`;
}
function compareNestedRankPathLex(a: number[], b: number[]): number {
const n = Math.max(a.length, b.length);
for (let i = 0; i < n; i++) {
const av = a[i];
const bv = b[i];
if (av === undefined && bv === undefined) return 0;
if (av === undefined) return -1;
if (bv === undefined) return 1;
if (av !== bv) return av - bv;
}
return 0;
}
function minNestedRankPath(paths: number[][]): number[] {
return paths.reduce((m, p) => (compareNestedRankPathLex(p, m) < 0 ? p : m));
}
/** Join-Knoten mit mehreren Vorgängern: einheitliche Zeilen-Stufe ``[max(pred)+1]``. */
function refineConvergenceNestedRanks(nodes: CanvasNode[], stripped: CanvasConnection[], ranks: Map<string, number[]>): void {
const preds = new Map<string, string[]>();
for (const n of nodes) preds.set(n.id, []);
for (const c of stripped) {
preds.get(c.targetId)!.push(c.sourceId);
}
const order = topologicalLayersIds(nodes, stripped).flat();
for (const id of order) {
const ps = preds.get(id) ?? [];
if (ps.length <= 1) continue;
let best = 0;
for (const p of ps) {
const rp = ranks.get(p);
if (!rp || rp.length === 0) continue;
best = Math.max(best, Math.max(...rp));
}
ranks.set(id, [best + 1]);
}
}
/** /**
* Topological-sort based auto-layout: arranges nodes top-to-bottom in layers. * Topologische Schichten (Kahn): chronologisch von Quellen zu Senken.
* Disconnected nodes are appended as extra roots. * Zyklen/notwendige Restknoten jeweils eigene Zeile wie bei klassischem Sugiyama-Setup.
*/ */
export function computeAutoLayout( function topologicalLayersIds(nodes: CanvasNode[], connections: CanvasConnection[]): string[][] {
nodes: CanvasNode[], if (nodes.length === 0) return [];
connections: CanvasConnection[],
): CanvasNode[] {
if (nodes.length === 0) return nodes;
const inDegree = new Map<string, number>(); const inDegree = new Map<string, number>();
const children = new Map<string, string[]>(); const children = new Map<string, string[]>();
@ -162,6 +373,7 @@ export function computeAutoLayout(
children.set(n.id, []); children.set(n.id, []);
} }
for (const c of connections) { for (const c of connections) {
if (!inDegree.has(c.sourceId) || !inDegree.has(c.targetId)) continue;
inDegree.set(c.targetId, (inDegree.get(c.targetId) ?? 0) + 1); inDegree.set(c.targetId, (inDegree.get(c.targetId) ?? 0) + 1);
children.get(c.sourceId)?.push(c.targetId); children.get(c.sourceId)?.push(c.targetId);
} }
@ -191,12 +403,32 @@ export function computeAutoLayout(
const placed = new Set(layerOf.keys()); const placed = new Set(layerOf.keys());
for (const n of nodes) { for (const n of nodes) {
if (!placed.has(n.id)) { if (!placed.has(n.id)) {
const layerIdx = layers.length;
layers.push([n.id]); layers.push([n.id]);
layerOf.set(n.id, layerIdx); layerOf.set(n.id, layers.length - 1);
} }
} }
return layers;
}
/**
* Topological-sort based auto-layout: arranges nodes top-to-bottom in layers.
* Disconnected nodes are appended as extra roots.
*/
export function computeAutoLayout(
nodes: CanvasNode[],
connections: CanvasConnection[],
): CanvasNode[] {
if (nodes.length === 0) return nodes;
const layers = topologicalLayersIds(nodes, connections);
for (const layer of layers) {
layer.sort((a, b) => a.localeCompare(b));
}
const layerOf = new Map<string, number>();
layers.forEach((layer, li) => layer.forEach((id) => layerOf.set(id, li)));
const startX = 40; const startX = 40;
const startY = 40; const startY = 40;
@ -212,6 +444,84 @@ export function computeAutoLayout(
}); });
} }
/**
* Raster-Anordnung über **verschachtelte Rangpfade** (z.B. ``4.1`` und ``4.2`` dieselbe Zeile):
* DFS auf einem Primärbaum (längster Pfad zuerst, ohne Loop-Rückkopplung für die Schichtung).
* Ein-Stufen-Pfade ``[1],[2],[3]`` jeweils eigene Zeile; gemeinsamer Präfix bei Zweigen gemeinsame Rasterzeile.
* Zeilen untereinander unter der Mitte der darüberliegenden Zeile zentriert.
*/
export function computeGridTidyLayout(nodes: CanvasNode[], connections: CanvasConnection[]): CanvasNode[] {
if (nodes.length === 0) return nodes;
const stripped = stripLoopFeedbackConnections(nodes, connections);
const ranks = assignNestedRankPaths(nodes, stripped);
refineConvergenceNestedRanks(nodes, stripped, ranks);
const rowBuckets = new Map<string, CanvasNode[]>();
for (const n of nodes) {
const path = ranks.get(n.id);
if (!path) continue;
const key = nestedRankRowGroupKey(path);
if (!rowBuckets.has(key)) rowBuckets.set(key, []);
rowBuckets.get(key)!.push(n);
}
const rowEntries = [...rowBuckets.values()].map((members) => {
const paths = members.map((m) => ranks.get(m.id)!);
return {
members,
rowOrderKey: minNestedRankPath(paths),
};
});
rowEntries.sort((a, b) => compareNestedRankPathLex(a.rowOrderKey, b.rowOrderKey));
const rows = rowEntries.map((e) =>
orderRowByIntraRowTopo(
[...e.members].sort((na, nb) =>
compareNestedRankPathLex(ranks.get(na.id)!, ranks.get(nb.id)!),
),
connections,
),
);
const rowSpanX = (count: number) =>
count <= 0 ? 0 : count * NODE_WIDTH + (count - 1) * LAYOUT_H_GAP;
const startX = 40;
const startY = 40;
const out = new Map<string, CanvasNode>();
let prevLeft = startX;
let prevCount = rows[0]?.length ?? 0;
rows.forEach((r, ri) => {
const nInRow = r.length;
let left: number;
if (ri === 0) {
left = startX;
} else {
const centerAbove = prevLeft + rowSpanX(prevCount) / 2;
left = centerAbove - rowSpanX(nInRow) / 2;
if (left < 8) left = 8;
}
const y = startY + ri * (NODE_HEIGHT + LAYOUT_V_GAP);
r.forEach((n, ci) => {
out.set(n.id, {
...n,
x: left + ci * (NODE_WIDTH + LAYOUT_H_GAP),
y,
});
});
prevLeft = left;
prevCount = nInRow;
});
return nodes.map((n) => out.get(n.id) ?? n);
}
function _outputSchemaName(schema: string | GraphDefinedSchemaRef | undefined): string { function _outputSchemaName(schema: string | GraphDefinedSchemaRef | undefined): string {
if (typeof schema === 'string') return schema; if (typeof schema === 'string') return schema;
if (schema && typeof schema === 'object' && schema.kind === 'fromGraph') return 'FormPayload'; if (schema && typeof schema === 'object' && schema.kind === 'fromGraph') return 'FormPayload';
@ -248,13 +558,6 @@ function allowsMultipleInboundOnInputPort(targetNode: CanvasNode, targetHandleIn
return targetNode.type === 'flow.loop' && targetHandleIndex === 0; return targetNode.type === 'flow.loop' && targetHandleIndex === 0;
} }
/** Kanten-Rücklauf visuell links um die Knoten zur Loop oben. */
function isLoopFeedbackEdge(c: CanvasConnection, srcNode: CanvasNode, tgtNode: CanvasNode): boolean {
if (tgtNode.type !== 'flow.loop' || c.targetHandle !== 0) return false;
if (c.sourceId === c.targetId) return true;
return srcNode.y > tgtNode.y + 4;
}
const NODE_OBSTACLE_PAD = 12; const NODE_OBSTACLE_PAD = 12;
type Obstacle = { left: number; top: number; right: number; bottom: number }; type Obstacle = { left: number; top: number; right: number; bottom: number };
@ -661,6 +964,11 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
setSelectedStickyId(id); setSelectedStickyId(id);
setStickyFocusSelectAll(true); setStickyFocusSelectAll(true);
}, },
arrangeNodes: () => {
if (nodes.length === 0) return;
onNodesChange(computeGridTidyLayout(nodes, connections));
emitHistoryCheckpoint();
},
toggleConnectionTool: () => { toggleConnectionTool: () => {
setConnectionToolActive((p) => !p); setConnectionToolActive((p) => !p);
setPendingConnClickSource(null); setPendingConnClickSource(null);