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(),
|
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(),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue