diff --git a/src/main/frontend/node-editor/node-editor.js b/src/main/frontend/node-editor/node-editor.js index 631cfe7..ab17f33 100644 --- a/src/main/frontend/node-editor/node-editor.js +++ b/src/main/frontend/node-editor/node-editor.js @@ -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'; diff --git a/src/main/frontend/node-editor/script-editor.js b/src/main/frontend/node-editor/script-editor.js index 53774de..89ce244 100644 --- a/src/main/frontend/node-editor/script-editor.js +++ b/src/main/frontend/node-editor/script-editor.js @@ -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';