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:
@@ -5,7 +5,7 @@ import 'drawflow/dist/drawflow.min.css';
|
||||
// senkrecht in den Eingang des Ziels eintritt, bevor sie abbiegt.
|
||||
const EXIT_STUB = 5;
|
||||
|
||||
function customCurvature(sx, sy, ex, ey) {
|
||||
function verticalCurvature(sx, sy, ex, ey) {
|
||||
const dx = ex - sx;
|
||||
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}`;
|
||||
}
|
||||
|
||||
// --- 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) {
|
||||
Drawflow.prototype.createCurvature = customCurvature;
|
||||
Drawflow.prototype.createCurvature = verticalCurvature;
|
||||
}
|
||||
|
||||
const editors = new WeakMap();
|
||||
@@ -82,7 +221,6 @@ function attach(host) {
|
||||
editor.reroute = true;
|
||||
editor.reroute_fix_curvature = true;
|
||||
editor.curvature = 0.5;
|
||||
editor.createCurvature = customCurvature;
|
||||
editor.start();
|
||||
|
||||
const state = {
|
||||
@@ -92,6 +230,13 @@ function attach(host) {
|
||||
};
|
||||
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 => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
|
||||
@@ -30,6 +30,136 @@ function horizontalCurvature(sx, sy, 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).
|
||||
if (!window.__scriptEditorDragInit) {
|
||||
window.__scriptEditorDragInit = true;
|
||||
@@ -218,13 +348,19 @@ function attach(host) {
|
||||
|
||||
const editor = new Drawflow(container);
|
||||
editor.reroute = true;
|
||||
// Eigene horizontale Kurvenführung (überschreibt die vertikale aus dem Workflow-Editor).
|
||||
editor.createCurvature = horizontalCurvature;
|
||||
editor.start();
|
||||
|
||||
const state = { editor, container, counter: 0, variables: [] };
|
||||
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 => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
||||
|
||||
Reference in New Issue
Block a user