feat: hindernis-bewusste Linienführung in den Node-Editoren

Verbindungslinien meiden nach Möglichkeit andere Kacheln. Kreuzt die direkte
Kurve eine Kachel (bzw. bei Rückwärtskanten die Quell-/Zielkachel), wird die
Linie über eine freie Spalte seitlich an den Kacheln vorbei umgeleitet und mit
abgerundeten Ecken gezeichnet. Treffererkennung tastet den tatsächlichen
SVG-Pfad ab; die Führung wird bei nodeMoved dynamisch neu berechnet.

- node-editor.js: vertikale Port-Ausrichtung (Ausgang unten -> Eingang oben)
- script-editor.js: horizontale Port-Ausrichtung (Ausgang rechts -> Eingang links)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:03:54 +02:00
parent 67c92bceb3
commit efca20c5f3
2 changed files with 286 additions and 5 deletions

View File

@@ -5,7 +5,7 @@ import 'drawflow/dist/drawflow.min.css';
// senkrecht in den Eingang des Ziels eintritt, bevor sie abbiegt. // senkrecht in den Eingang des Ziels eintritt, bevor sie abbiegt.
const EXIT_STUB = 5; const EXIT_STUB = 5;
function customCurvature(sx, sy, ex, ey) { function verticalCurvature(sx, sy, ex, ey) {
const dx = ex - sx; const dx = ex - sx;
const minOffset = 40; const minOffset = 40;
@@ -31,8 +31,147 @@ function customCurvature(sx, sy, ex, ey) {
return ` M ${sx} ${sy} L ${sx} ${sy2} C ${c1x} ${c1y} ${c2x} ${c2y} ${ex} ${ey2} L ${ex} ${ey}`; return ` M ${sx} ${sy} L ${sx} ${sy2} C ${c1x} ${c1y} ${c2x} ${c2y} ${ex} ${ey2} L ${ex} ${ey}`;
} }
// --- Hindernis-bewusste Linienführung -------------------------------------
// Ports liegen oben (Eingang) und unten (Ausgang). Damit Linien nach
// Möglichkeit nicht über andere Kacheln (oder bei Rückwärtskanten über Quell-/
// Zielkachel) laufen, werden kreuzende Verbindungen seitlich an den Kacheln
// vorbei über eine freie Spalte umgeleitet.
const ROUTE_STUB = 16; // senkrechte Aus-/Eintrittsstrecke an den Ports
const ROUTE_PAD = 18; // Sicherheitsabstand um jede Kachel
const ROUTE_RADIUS = 14; // Eckenradius der Umleitung
// Alle Knoten-Rechtecke (Canvas-Koordinaten, inkl. Sicherheitsabstand).
function nodeRects(state) {
const rects = [];
state.container.querySelectorAll('.drawflow-node').forEach(el => {
const left = parseFloat(el.style.left) || 0;
const top = parseFloat(el.style.top) || 0;
const w = el.offsetWidth || 0;
const h = el.offsetHeight || 0;
if (!w || !h) return;
rects.push({
left: left - ROUTE_PAD, top: top - ROUTE_PAD,
right: left + w + ROUTE_PAD, bottom: top + h + ROUTE_PAD
});
});
return rects;
}
// Rechtecke ohne die Quell-/Zielkachel (die die Endpunkte enthalten).
function withoutEndpointRects(rects, sx, sy, ex, ey) {
return rects.filter(r => {
const hasStart = sx >= r.left && sx <= r.right && sy >= r.top && sy <= r.bottom;
const hasEnd = ex >= r.left && ex <= r.right && ey >= r.top && ey <= r.bottom;
return !hasStart && !hasEnd;
});
}
function pointInAnyRect(x, y, rects) {
return rects.some(r => x >= r.left && x <= r.right && y >= r.top && y <= r.bottom);
}
// Tastet einen beliebigen SVG-Pfad ab und prüft, ob er über eine Kachel läuft.
function pathHits(d, rects) {
if (!rects.length) return false;
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', d);
let total = 0;
try { total = p.getTotalLength(); } catch (e) { return false; }
if (!total) return false;
const steps = Math.max(16, Math.min(120, Math.round(total / 10)));
for (let i = 1; i < steps; i++) {
const pt = p.getPointAtLength((i / steps) * total);
if (pointInAnyRect(pt.x, pt.y, rects)) return true;
}
return false;
}
// Treffer-Test für achsenparallele Wegpunkte (Segment-BBox == Segment).
function segmentsHit(points, rects) {
for (let i = 0; i < points.length - 1; i++) {
const a = points[i], b = points[i + 1];
const left = Math.min(a.x, b.x), right = Math.max(a.x, b.x);
const top = Math.min(a.y, b.y), bottom = Math.max(a.y, b.y);
if (rects.some(r => right >= r.left && left <= r.right && bottom >= r.top && top <= r.bottom)) {
return true;
}
}
return false;
}
// Wegpunkte: aus dem Ausgang nach unten, über eine freie Spalte (channelX)
// und von oben in den Eingang.
function channelWaypoints(sx, sy, ex, ey, channelX) {
const ay = sy + ROUTE_STUB;
const by = ey - ROUTE_STUB;
return [
{ x: sx, y: sy }, { x: sx, y: ay }, { x: channelX, y: ay },
{ x: channelX, y: by }, { x: ex, y: by }, { x: ex, y: ey }
];
}
// Glatter Pfad mit abgerundeten Ecken durch achsenparallele Wegpunkte.
function roundedPath(pts, radius) {
const points = [];
for (const p of pts) {
const last = points[points.length - 1];
if (!last || Math.abs(last.x - p.x) > 0.5 || Math.abs(last.y - p.y) > 0.5) points.push(p);
}
if (points.length < 2) return '';
if (points.length === 2) {
return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;
}
let d = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length - 1; i++) {
const p0 = points[i - 1], p1 = points[i], p2 = points[i + 1];
const v1x = p1.x - p0.x, v1y = p1.y - p0.y;
const v2x = p2.x - p1.x, v2y = p2.y - p1.y;
const len1 = Math.hypot(v1x, v1y) || 1;
const len2 = Math.hypot(v2x, v2y) || 1;
const rr = Math.min(radius, len1 / 2, len2 / 2);
const s1x = p1.x - (v1x / len1) * rr, s1y = p1.y - (v1y / len1) * rr;
const e1x = p1.x + (v2x / len2) * rr, e1y = p1.y + (v2y / len2) * rr;
d += ` L ${s1x} ${s1y} Q ${p1.x} ${p1.y} ${e1x} ${e1y}`;
}
const last = points[points.length - 1];
d += ` L ${last.x} ${last.y}`;
return d;
}
// Liefert den SVG-Pfad einer Verbindung direkt, falls frei, sonst umgeleitet.
function routeConnection(state, sx, sy, ex, ey) {
const direct = verticalCurvature(sx, sy, ex, ey);
const all = nodeRects(state);
const others = withoutEndpointRects(all, sx, sy, ex, ey);
// Klare Vorwärtskante ohne fremde Kachel im Weg -> bisherige Kurve behalten.
const isBackward = ey < sy + 2 * ROUTE_STUB;
if (!isBackward && !pathHits(direct, others)) return direct;
// Bei Rückwärtskanten auch um Quell-/Zielkachel herum, bei Vorwärtskanten
// nur um fremde Kacheln.
const candidates = isBackward ? all : others;
const spanTop = Math.min(sy, ey) - ROUTE_STUB;
const spanBottom = Math.max(sy, ey) + ROUTE_STUB;
const blockers = candidates.filter(r => r.bottom > spanTop && r.top < spanBottom);
if (!blockers.length) return direct;
const ref = (sx + ex) / 2;
const leftX = Math.min(...blockers.map(r => r.left)) - ROUTE_PAD;
const rightX = Math.max(...blockers.map(r => r.right)) + ROUTE_PAD;
const first = Math.abs(leftX - ref) <= Math.abs(rightX - ref) ? leftX : rightX;
const second = first === leftX ? rightX : leftX;
let wp = channelWaypoints(sx, sy, ex, ey, first);
if (segmentsHit(wp, others)) {
const alt = channelWaypoints(sx, sy, ex, ey, second);
if (!segmentsHit(alt, others)) wp = alt;
}
return roundedPath(wp, ROUTE_RADIUS);
}
if (Drawflow && Drawflow.prototype) { if (Drawflow && Drawflow.prototype) {
Drawflow.prototype.createCurvature = customCurvature; Drawflow.prototype.createCurvature = verticalCurvature;
} }
const editors = new WeakMap(); const editors = new WeakMap();
@@ -82,7 +221,6 @@ function attach(host) {
editor.reroute = true; editor.reroute = true;
editor.reroute_fix_curvature = true; editor.reroute_fix_curvature = true;
editor.curvature = 0.5; editor.curvature = 0.5;
editor.createCurvature = customCurvature;
editor.start(); editor.start();
const state = { const state = {
@@ -92,6 +230,13 @@ function attach(host) {
}; };
editors.set(host, state); editors.set(host, state);
// Hindernis-bewusste Linienführung. Reroute-Teilsegmente
// (type != 'openclose') nutzen die einfache vertikale Kurve.
editor.createCurvature = (sx, sy, ex, ey, curvature, type) =>
(type && type !== 'openclose')
? verticalCurvature(sx, sy, ex, ey)
: routeConnection(state, sx, sy, ex, ey);
container.addEventListener('dragover', e => { container.addEventListener('dragover', e => {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';

View File

@@ -30,6 +30,136 @@ function horizontalCurvature(sx, sy, ex, ey) {
return ` M ${sx} ${sy} C ${sx + offset} ${sy} ${ex - offset} ${ey} ${ex} ${ey}`; return ` M ${sx} ${sy} C ${sx + offset} ${sy} ${ex - offset} ${ey} ${ex} ${ey}`;
} }
// --- Hindernis-bewusste Linienführung -------------------------------------
// createCurvature kennt nur die Endpunkte der Verbindung. Damit Linien nach
// Möglichkeit nicht über andere Kacheln laufen, lesen wir die Knoten-Rechtecke
// (in Canvas-Koordinaten) direkt aus dem DOM und leiten eine kreuzende Linie
// über einen freien horizontalen Kanal ober- oder unterhalb der Kacheln um.
const ROUTE_STUB = 26; // waagerechte Aus-/Eintrittsstrecke an den Ports
const ROUTE_PAD = 14; // Sicherheitsabstand um jede Kachel
const ROUTE_RADIUS = 12; // Eckenradius der Umleitung
// Knoten-Rechtecke als Hindernisse; Quell-/Zielknoten (die die Endpunkte
// enthalten) werden ausgeschlossen.
function obstacleRects(state, sx, sy, ex, ey) {
const rects = [];
state.container.querySelectorAll('.drawflow-node').forEach(el => {
const left = parseFloat(el.style.left) || 0;
const top = parseFloat(el.style.top) || 0;
const w = el.offsetWidth || 0;
const h = el.offsetHeight || 0;
if (!w || !h) return;
const r = {
left: left - ROUTE_PAD, top: top - ROUTE_PAD,
right: left + w + ROUTE_PAD, bottom: top + h + ROUTE_PAD
};
const hasStart = sx >= r.left && sx <= r.right && sy >= r.top && sy <= r.bottom;
const hasEnd = ex >= r.left && ex <= r.right && ey >= r.top && ey <= r.bottom;
if (hasStart || hasEnd) return;
rects.push(r);
});
return rects;
}
function pointInAnyRect(x, y, rects) {
return rects.some(r => x >= r.left && x <= r.right && y >= r.top && y <= r.bottom);
}
// Läuft die (abgetastete) Bézier-Kurve über eine Kachel?
function bezierHits(sx, sy, c1x, c1y, c2x, c2y, ex, ey, rects) {
const N = 24;
for (let i = 1; i < N; i++) {
const t = i / N, u = 1 - t;
const x = u * u * u * sx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex;
const y = u * u * u * sy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey;
if (pointInAnyRect(x, y, rects)) return true;
}
return false;
}
// Treffer-Test für eine achsenparallele Wegpunktkette (Segment-BBox == Segment).
function segmentsHit(points, rects) {
for (let i = 0; i < points.length - 1; i++) {
const a = points[i], b = points[i + 1];
const left = Math.min(a.x, b.x), right = Math.max(a.x, b.x);
const top = Math.min(a.y, b.y), bottom = Math.max(a.y, b.y);
if (rects.some(r => right >= r.left && left <= r.right && bottom >= r.top && top <= r.bottom)) {
return true;
}
}
return false;
}
// Wegpunkte: Aus dem Port heraus, über einen Kanal auf Höhe channelY und in
// den Zielport hinein.
function channelWaypoints(sx, sy, ex, ey, channelY) {
const ax = sx + ROUTE_STUB;
const bx = ex - ROUTE_STUB;
return [
{ x: sx, y: sy }, { x: ax, y: sy }, { x: ax, y: channelY },
{ x: bx, y: channelY }, { x: bx, y: ey }, { x: ex, y: ey }
];
}
// Glatter Pfad mit abgerundeten Ecken durch achsenparallele Wegpunkte.
function roundedPath(pts, radius) {
const points = [];
for (const p of pts) {
const last = points[points.length - 1];
if (!last || Math.abs(last.x - p.x) > 0.5 || Math.abs(last.y - p.y) > 0.5) points.push(p);
}
if (points.length < 2) return '';
if (points.length === 2) {
return `M ${points[0].x} ${points[0].y} L ${points[1].x} ${points[1].y}`;
}
let d = `M ${points[0].x} ${points[0].y}`;
for (let i = 1; i < points.length - 1; i++) {
const p0 = points[i - 1], p1 = points[i], p2 = points[i + 1];
const v1x = p1.x - p0.x, v1y = p1.y - p0.y;
const v2x = p2.x - p1.x, v2y = p2.y - p1.y;
const len1 = Math.hypot(v1x, v1y) || 1;
const len2 = Math.hypot(v2x, v2y) || 1;
const rr = Math.min(radius, len1 / 2, len2 / 2);
const s1x = p1.x - (v1x / len1) * rr, s1y = p1.y - (v1y / len1) * rr;
const e1x = p1.x + (v2x / len2) * rr, e1y = p1.y + (v2y / len2) * rr;
d += ` L ${s1x} ${s1y} Q ${p1.x} ${p1.y} ${e1x} ${e1y}`;
}
const last = points[points.length - 1];
d += ` L ${last.x} ${last.y}`;
return d;
}
// Liefert den SVG-Pfad einer Verbindung direkt, falls frei, sonst umgeleitet.
function routeConnection(state, sx, sy, ex, ey) {
const offset = Math.max(Math.abs(ex - sx) * 0.5, 40);
const c1x = sx + offset, c2x = ex - offset;
const direct = ` M ${sx} ${sy} C ${c1x} ${sy} ${c2x} ${ey} ${ex} ${ey}`;
const rects = obstacleRects(state, sx, sy, ex, ey);
if (rects.length === 0 || !bezierHits(sx, sy, c1x, sy, c2x, ey, ex, ey, rects)) {
return direct;
}
// Kacheln, die im waagerechten Bereich zwischen Quelle und Ziel liegen.
const spanLeft = Math.min(sx, ex), spanRight = Math.max(sx, ex);
const blockers = rects.filter(r => r.right > spanLeft && r.left < spanRight);
if (blockers.length === 0) return direct;
const ref = (sy + ey) / 2;
const aboveY = Math.min(...blockers.map(r => r.top)) - ROUTE_PAD;
const belowY = Math.max(...blockers.map(r => r.bottom)) + ROUTE_PAD;
const first = Math.abs(aboveY - ref) <= Math.abs(belowY - ref) ? aboveY : belowY;
const second = first === aboveY ? belowY : aboveY;
let wp = channelWaypoints(sx, sy, ex, ey, first);
if (segmentsHit(wp, rects)) {
const alt = channelWaypoints(sx, sy, ex, ey, second);
if (!segmentsHit(alt, rects)) wp = alt;
}
return roundedPath(wp, ROUTE_RADIUS);
}
// Drag-Start für die Operator-Kacheln (einmalig global registrieren). // Drag-Start für die Operator-Kacheln (einmalig global registrieren).
if (!window.__scriptEditorDragInit) { if (!window.__scriptEditorDragInit) {
window.__scriptEditorDragInit = true; window.__scriptEditorDragInit = true;
@@ -218,13 +348,19 @@ function attach(host) {
const editor = new Drawflow(container); const editor = new Drawflow(container);
editor.reroute = true; editor.reroute = true;
// Eigene horizontale Kurvenführung (überschreibt die vertikale aus dem Workflow-Editor).
editor.createCurvature = horizontalCurvature;
editor.start(); editor.start();
const state = { editor, container, counter: 0, variables: [] }; const state = { editor, container, counter: 0, variables: [] };
scriptEditors.set(host, state); scriptEditors.set(host, state);
// Hindernis-bewusste, horizontale Linienführung (statt der vertikalen aus
// dem Workflow-Editor). Für Verbindungssegmente mit Reroute-Punkten
// (type != 'openclose') greift die einfache Kurve.
editor.createCurvature = (sx, sy, ex, ey, curvature, type) =>
(type && type !== 'openclose')
? horizontalCurvature(sx, sy, ex, ey)
: routeConnection(state, sx, sy, ex, ey);
container.addEventListener('dragover', e => { container.addEventListener('dragover', e => {
e.preventDefault(); e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';