fix: anordnen knopf wieder hinzugefügt mit verschachtelten rangpfaden
This commit is contained in:
parent
600e0c87dc
commit
e3c93dc220
3 changed files with 338 additions and 16 deletions
|
|
@ -837,6 +837,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
onDeleteSelection: () => flowCanvasRef.current?.deleteSelection(),
|
||||
onDuplicateNode: () => flowCanvasRef.current?.duplicateSingleSelection(),
|
||||
onToggleConnectionTool: () => flowCanvasRef.current?.toggleConnectionTool(),
|
||||
onArrangeNodes: () => flowCanvasRef.current?.arrangeNodes(),
|
||||
onAddCanvasComment: () => flowCanvasRef.current?.addCanvasComment(),
|
||||
}),
|
||||
[
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineArrowLongRight,
|
||||
HiOutlineChatBubbleLeftEllipsis,
|
||||
HiOutlineSquares2X2,
|
||||
} from 'react-icons/hi2';
|
||||
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
|
@ -55,6 +56,8 @@ export interface CanvasHeaderCanvasEditProps {
|
|||
onToggleConnectionTool: () => void;
|
||||
/** Textnotiz auf die Canvas legen (ohne Workflow-Daten). */
|
||||
onAddCanvasComment: () => void;
|
||||
/** Verschachtelte Rasterpfade (4.1 / 4.2 …); Haftnotizen unberührt. */
|
||||
onArrangeNodes: () => void;
|
||||
}
|
||||
|
||||
interface CanvasHeaderProps {
|
||||
|
|
@ -525,6 +528,16 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
|||
>
|
||||
<HiOutlineArrowLongRight size={18} strokeWidth={2} aria-hidden />
|
||||
</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
|
||||
type="button"
|
||||
className={styles.canvasHeaderGhostIconBtn}
|
||||
|
|
|
|||
|
|
@ -139,21 +139,232 @@ export type FlowCanvasHandle = {
|
|||
toggleConnectionTool: () => void;
|
||||
/** Fügt eine bearbeitbare Textnotiz in der Mitte der sichtbaren Canvas ein. */
|
||||
addCanvasComment: () => void;
|
||||
/** Raster-Anordnung: verschachtelte Rangpfade (4.1 / 4.2 …); Haftnotizen unberührt. */
|
||||
arrangeNodes: () => void;
|
||||
};
|
||||
const HANDLE_SIZE = 12;
|
||||
const HANDLE_OFFSET = HANDLE_SIZE / 2;
|
||||
const LAYOUT_V_GAP = 80;
|
||||
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.
|
||||
* Disconnected nodes are appended as extra roots.
|
||||
* Topologische Schichten (Kahn): chronologisch von Quellen zu Senken.
|
||||
* Zyklen/notwendige Restknoten jeweils eigene Zeile wie bei klassischem Sugiyama-Setup.
|
||||
*/
|
||||
export function computeAutoLayout(
|
||||
nodes: CanvasNode[],
|
||||
connections: CanvasConnection[],
|
||||
): CanvasNode[] {
|
||||
if (nodes.length === 0) return nodes;
|
||||
function topologicalLayersIds(nodes: CanvasNode[], connections: CanvasConnection[]): string[][] {
|
||||
if (nodes.length === 0) return [];
|
||||
|
||||
const inDegree = new Map<string, number>();
|
||||
const children = new Map<string, string[]>();
|
||||
|
|
@ -162,6 +373,7 @@ export function computeAutoLayout(
|
|||
children.set(n.id, []);
|
||||
}
|
||||
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);
|
||||
children.get(c.sourceId)?.push(c.targetId);
|
||||
}
|
||||
|
|
@ -191,12 +403,32 @@ export function computeAutoLayout(
|
|||
const placed = new Set(layerOf.keys());
|
||||
for (const n of nodes) {
|
||||
if (!placed.has(n.id)) {
|
||||
const layerIdx = layers.length;
|
||||
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 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 {
|
||||
if (typeof schema === 'string') return schema;
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
type Obstacle = { left: number; top: number; right: number; bottom: number };
|
||||
|
|
@ -661,6 +964,11 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
|
|||
setSelectedStickyId(id);
|
||||
setStickyFocusSelectAll(true);
|
||||
},
|
||||
arrangeNodes: () => {
|
||||
if (nodes.length === 0) return;
|
||||
onNodesChange(computeGridTidyLayout(nodes, connections));
|
||||
emitHistoryCheckpoint();
|
||||
},
|
||||
toggleConnectionTool: () => {
|
||||
setConnectionToolActive((p) => !p);
|
||||
setPendingConnClickSource(null);
|
||||
|
|
|
|||
Loading…
Reference in a new issue