From 1332ce28530c3a66b8d54253eeb46c6ff6cbc284 Mon Sep 17 00:00:00 2001 From: Haider Date: Thu, 9 Jul 2026 16:32:09 +0200 Subject: [PATCH 1/5] chore(files): better structuring of SvelteFlow frontend components --- .../SvelteFlowClassContextMenu.svelte | 10 +- .../SvelteFlowPaneContextMenu.svelte | 6 +- .../{ => contextmenu}/contextMenuUtils.js | 0 .../{ => edge}/AssociationEdge.svelte | 75 ++++++++--- .../components/{ => edge}/EdgeMarkers.svelte | 0 .../{ => edge}/InheritanceEdge.svelte | 0 .../svelteflow/components/edgeUtils.ts | 123 ------------------ 7 files changed, 66 insertions(+), 148 deletions(-) rename frontend/src/lib/rendering/svelteflow/components/{ => contextmenu}/SvelteFlowClassContextMenu.svelte (95%) rename frontend/src/lib/rendering/svelteflow/components/{ => contextmenu}/SvelteFlowPaneContextMenu.svelte (96%) rename frontend/src/lib/rendering/svelteflow/components/{ => contextmenu}/contextMenuUtils.js (100%) rename frontend/src/lib/rendering/svelteflow/components/{ => edge}/AssociationEdge.svelte (68%) rename frontend/src/lib/rendering/svelteflow/components/{ => edge}/EdgeMarkers.svelte (100%) rename frontend/src/lib/rendering/svelteflow/components/{ => edge}/InheritanceEdge.svelte (100%) delete mode 100644 frontend/src/lib/rendering/svelteflow/components/edgeUtils.ts diff --git a/frontend/src/lib/rendering/svelteflow/components/SvelteFlowClassContextMenu.svelte b/frontend/src/lib/rendering/svelteflow/components/contextmenu/SvelteFlowClassContextMenu.svelte similarity index 95% rename from frontend/src/lib/rendering/svelteflow/components/SvelteFlowClassContextMenu.svelte rename to frontend/src/lib/rendering/svelteflow/components/contextmenu/SvelteFlowClassContextMenu.svelte index fc70060b..1d32ce46 100644 --- a/frontend/src/lib/rendering/svelteflow/components/SvelteFlowClassContextMenu.svelte +++ b/frontend/src/lib/rendering/svelteflow/components/contextmenu/SvelteFlowClassContextMenu.svelte @@ -29,8 +29,8 @@ faDiagramProject, } from "@fortawesome/free-solid-svg-icons"; - import { ContextMenu } from "$lib/components/bitsui/contextmenu"; import ContextMenuSeparator from "$lib/components/bitsui/contextmenu/ContextMenuSeparator.svelte"; + import { ContextMenu } from "$lib/components/bitsui/contextmenu/index.js"; import { copyState, DiagramType, @@ -43,10 +43,10 @@ handleContextMenuOpenChange, syncContextMenuTrigger, } from "./contextMenuUtils.js"; - import DeleteDependenciesDialog from "../../../../routes/delete-relations-dialog/DeleteDependenciesDialog.svelte"; - import RemoveFromDiagramDialog from "../../../../routes/mainpage/packageNavigation/custom-diagram-dialogs/RemoveFromDiagramDialog.svelte"; - import ExtendClassDialog from "../../../../routes/mainpage/packageNavigation/ExtendClassDialog.svelte"; - import SHACLClassSpecificPopUp from "../../../../routes/shacl/shaclclassspecific/SHACLClassSpecificPopUp.svelte"; + import DeleteDependenciesDialog from "../../../../../routes/delete-relations-dialog/DeleteDependenciesDialog.svelte"; + import RemoveFromDiagramDialog from "../../../../../routes/mainpage/packageNavigation/custom-diagram-dialogs/RemoveFromDiagramDialog.svelte"; + import ExtendClassDialog from "../../../../../routes/mainpage/packageNavigation/ExtendClassDialog.svelte"; + import SHACLClassSpecificPopUp from "../../../../../routes/shacl/shaclclassspecific/SHACLClassSpecificPopUp.svelte"; let { request = null, diff --git a/frontend/src/lib/rendering/svelteflow/components/SvelteFlowPaneContextMenu.svelte b/frontend/src/lib/rendering/svelteflow/components/contextmenu/SvelteFlowPaneContextMenu.svelte similarity index 96% rename from frontend/src/lib/rendering/svelteflow/components/SvelteFlowPaneContextMenu.svelte rename to frontend/src/lib/rendering/svelteflow/components/contextmenu/SvelteFlowPaneContextMenu.svelte index 95c29518..5508df7b 100644 --- a/frontend/src/lib/rendering/svelteflow/components/SvelteFlowPaneContextMenu.svelte +++ b/frontend/src/lib/rendering/svelteflow/components/contextmenu/SvelteFlowPaneContextMenu.svelte @@ -19,7 +19,7 @@ import { faPaste, faPlus } from "@fortawesome/free-solid-svg-icons"; import { BackendConnection } from "$lib/api/backend.js"; - import { ContextMenu } from "$lib/components/bitsui/contextmenu"; + import { ContextMenu } from "$lib/components/bitsui/contextmenu/index.js"; import { PUBLIC_BACKEND_URL } from "$lib/config/runtime.js"; import { copyState, @@ -32,8 +32,8 @@ handleContextMenuOpenChange, syncContextMenuTrigger, } from "./contextMenuUtils.js"; - import { saveCopyClass } from "../../../../routes/mainpage/packageNavigation/save-copy-class-to-backend.js"; - import NewClassDialog from "../../../../routes/NewClassDialog.svelte"; + import { saveCopyClass } from "../../../../../routes/mainpage/packageNavigation/save-copy-class-to-backend.js"; + import NewClassDialog from "../../../../../routes/NewClassDialog.svelte"; let { request = null, diff --git a/frontend/src/lib/rendering/svelteflow/components/contextMenuUtils.js b/frontend/src/lib/rendering/svelteflow/components/contextmenu/contextMenuUtils.js similarity index 100% rename from frontend/src/lib/rendering/svelteflow/components/contextMenuUtils.js rename to frontend/src/lib/rendering/svelteflow/components/contextmenu/contextMenuUtils.js diff --git a/frontend/src/lib/rendering/svelteflow/components/AssociationEdge.svelte b/frontend/src/lib/rendering/svelteflow/components/edge/AssociationEdge.svelte similarity index 68% rename from frontend/src/lib/rendering/svelteflow/components/AssociationEdge.svelte rename to frontend/src/lib/rendering/svelteflow/components/edge/AssociationEdge.svelte index e68c869c..1fd43aca 100644 --- a/frontend/src/lib/rendering/svelteflow/components/AssociationEdge.svelte +++ b/frontend/src/lib/rendering/svelteflow/components/edge/AssociationEdge.svelte @@ -20,18 +20,22 @@ BaseEdge, EdgeLabel, getStraightPath, + useEdges, useInternalNode, } from "@xyflow/svelte"; import { userSettings } from "$lib/userSettings.svelte.js"; - import { getEdgeParams } from "./edgeUtils.ts"; + import EdgeBendPoints from "./EdgeBendPoints.svelte"; + import { getEdgeParams, getPolylinePath } from "./edgeUtils.ts"; + let { id, source, target, data, selected } = $props(); + const edges = useEdges(); - let { id, source, target, data } = $props(); let markerEnd = data.useToAssociation ? "url(#associationTo)" : ""; let markerStart = data.useFromAssociation ? "url(#associationFrom)" : ""; let sourceNode = useInternalNode(source); let targetNode = useInternalNode(target); + let bendPoints = $derived(data.bendPoints ?? []); let style = $derived( userSettings.get("useColoredPropertiesInMergedView") && data.color @@ -41,20 +45,35 @@ let edgeParams = $derived.by(() => { if (sourceNode.current && targetNode.current) { - return getEdgeParams(sourceNode.current, targetNode.current); + return getEdgeParams( + sourceNode.current, + targetNode.current, + 0, + bendPoints, + ); } }); + //TODO hier später am besten in ne getPath methode auslagern let path = $derived.by(() => { if (!edgeParams) return ""; - return target === source && sourceNode.current - ? getSelfConnectingPath() - : getStraightPath({ - sourceX: edgeParams.sx, - sourceY: edgeParams.sy, - targetX: edgeParams.tx, - targetY: edgeParams.ty, - })[0]; + if (target === source && sourceNode.current) + return getSelfConnectingPath(); + + if (bendPoints.length > 0) { + return getPolylinePath([ + { x: edgeParams.sx, y: edgeParams.sy }, + ...bendPoints, + { x: edgeParams.tx, y: edgeParams.ty }, + ]); + } + + return getStraightPath({ + sourceX: edgeParams.sx, + sourceY: edgeParams.sy, + targetX: edgeParams.tx, + targetY: edgeParams.ty, + })[0]; }); function getSelfConnectingPath() { @@ -63,18 +82,40 @@ y: 0, }; const w = sourceNode.current.measured.width ?? 100; - const x1 = pos.x + w * 0.25; const y1 = pos.y; const x2 = pos.x + w * 0.75; const y2 = pos.y; - const loopHeight = 70; return `M ${x1} ${y1} C ${x1} ${y1 - loopHeight}, ${x2} ${y2 - loopHeight}, ${x2} ${y2}`; } + + function handleBendPointsChange(nextPoints) { + edges.update(current => + current.map(edge => + edge.id === id + ? { + ...edge, + data: { ...edge.data, bendPoints: nextPoints }, + } + : edge, + ), + ); + } + +{#if selected && edgeParams && target !== source} + +{/if} + {#if data.toMultiplicity} {#if target === source && sourceNode.current} @@ -85,14 +126,14 @@ {@const w = sourceNode.current.measured.width ?? 100}
{data.toMultiplicity}
{:else}
{data.toMultiplicity}
@@ -107,14 +148,14 @@ {@const w = targetNode.current.measured.width ?? 100}
{data.fromMultiplicity}
{:else}
{data.fromMultiplicity}
diff --git a/frontend/src/lib/rendering/svelteflow/components/EdgeMarkers.svelte b/frontend/src/lib/rendering/svelteflow/components/edge/EdgeMarkers.svelte similarity index 100% rename from frontend/src/lib/rendering/svelteflow/components/EdgeMarkers.svelte rename to frontend/src/lib/rendering/svelteflow/components/edge/EdgeMarkers.svelte diff --git a/frontend/src/lib/rendering/svelteflow/components/InheritanceEdge.svelte b/frontend/src/lib/rendering/svelteflow/components/edge/InheritanceEdge.svelte similarity index 100% rename from frontend/src/lib/rendering/svelteflow/components/InheritanceEdge.svelte rename to frontend/src/lib/rendering/svelteflow/components/edge/InheritanceEdge.svelte diff --git a/frontend/src/lib/rendering/svelteflow/components/edgeUtils.ts b/frontend/src/lib/rendering/svelteflow/components/edgeUtils.ts deleted file mode 100644 index e338fe19..00000000 --- a/frontend/src/lib/rendering/svelteflow/components/edgeUtils.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright (c) 2024-2026 SOPTIM AG - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -import { type InternalNode } from "@xyflow/svelte"; - -/** - * Calculates the intersection point between a line (from the target to the node center) - * and the border of the intersection node. Used to determine edge start and end points. - * This and the following methods are adapted from the SvelteFlow "Easy Connect" example. - * See: https://svelteflow.dev/examples/nodes/easy-connect - */ -function getNodeIntersection( - intersectionNode: InternalNode, - targetNode: InternalNode, - offsetY: number = 0, -) { - const intersectionPos = intersectionNode.internals.positionAbsolute || { - x: 0, - y: 0, - }; - const targetPos = targetNode.internals.positionAbsolute || { x: 0, y: 0 }; - - const w = (intersectionNode.measured.width ?? 0) / 2; - const h = (intersectionNode.measured.height ?? 0) / 2; - - const x2 = intersectionPos.x + w; - const y2 = intersectionPos.y + h; - const x1 = targetPos.x + (targetNode.measured.width ?? 0) / 2; - const y1 = targetPos.y + (targetNode.measured.height ?? 0) / 2 + offsetY; - - const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); - const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); - - const a = 1 / (Math.abs(xx1) + Math.abs(yy1)); - const xx3 = a * xx1; - const yy3 = a * yy1; - - return { - x: w * (xx3 + yy3) + x2, - y: h * (-xx3 + yy3) + y2, - }; -} - -/** - * Calculates the label offsets along an edge based on the angle of the connection line. - * Used to position start and end labels. - */ -function getLabelOffsets(sx: number, sy: number, tx: number, ty: number) { - const ALONG_EDGE_DISTANCE = 20; - const PERPENDICULAR_DISTANCE = 14; - - const dx = tx - sx; - const dy = ty - sy; - const length = Math.sqrt(dx * dx + dy * dy) || 1; - - const normDx = dx / length; - const normDy = dy / length; - - const angle = Math.atan2(-dy, dx) * (180 / Math.PI); - const normalizedAngle = (angle + 360) % 360; - - const labelOnLeft = - (normalizedAngle >= 90 && normalizedAngle < 180) || - normalizedAngle >= 270; - const side = labelOnLeft ? 1 : -1; - - const alongX = normDx * ALONG_EDGE_DISTANCE; - const alongY = normDy * ALONG_EDGE_DISTANCE; - const perpX = -normDy * PERPENDICULAR_DISTANCE * side; - const perpY = normDx * PERPENDICULAR_DISTANCE * side; - - return { - startX: alongX + perpX, - startY: alongY + perpY, - endX: -alongX + perpX, - endY: -alongY + perpY, - }; -} - -/** - * Returns all parameters needed to render an edge and its labels. - * Contains the start/end points of the edge as well as the calculated label positions. - */ -export function getEdgeParams( - source: InternalNode, - target: InternalNode, - offsetY: number = 0, -) { - const sourceIntersection = getNodeIntersection(source, target); - const targetIntersection = getNodeIntersection(target, source, offsetY); - - const labelOffsets = getLabelOffsets( - sourceIntersection.x, - sourceIntersection.y, - targetIntersection.x, - targetIntersection.y, - ); - - return { - sx: sourceIntersection.x, - sy: sourceIntersection.y, - tx: targetIntersection.x, - ty: targetIntersection.y, - startX: labelOffsets.startX, - startY: labelOffsets.startY, - endX: labelOffsets.endX, - endY: labelOffsets.endY, - }; -} From ba9dc3167a7436f352d598f0c62c00fb06c4e92d Mon Sep 17 00:00:00 2001 From: Haider Date: Tue, 14 Jul 2026 16:30:13 +0200 Subject: [PATCH 2/5] feat(multi-segment-edges): initial rendering functionality with SVG circle bend points + interaction via context menu --- .../SvelteFlowEdgeContextMenu.svelte | 126 +++++++++ .../components/edge/EdgeBendPoints.svelte | 133 +++++++++ .../svelteflow/components/edge/edgeUtils.ts | 267 ++++++++++++++++++ .../interaction/bendPointOperations.js | 64 +++++ .../interaction/contextMenus.svelte.js | 60 +++- .../svelteflow/svelteFlowWrapper.svelte | 120 +++++++- .../routes/mainpage/renderingWrapper.svelte | 113 +++++++- 7 files changed, 874 insertions(+), 9 deletions(-) create mode 100644 frontend/src/lib/rendering/svelteflow/components/contextmenu/SvelteFlowEdgeContextMenu.svelte create mode 100644 frontend/src/lib/rendering/svelteflow/components/edge/EdgeBendPoints.svelte create mode 100644 frontend/src/lib/rendering/svelteflow/components/edge/edgeUtils.ts create mode 100644 frontend/src/lib/rendering/svelteflow/interaction/bendPointOperations.js diff --git a/frontend/src/lib/rendering/svelteflow/components/contextmenu/SvelteFlowEdgeContextMenu.svelte b/frontend/src/lib/rendering/svelteflow/components/contextmenu/SvelteFlowEdgeContextMenu.svelte new file mode 100644 index 00000000..5f2c839b --- /dev/null +++ b/frontend/src/lib/rendering/svelteflow/components/contextmenu/SvelteFlowEdgeContextMenu.svelte @@ -0,0 +1,126 @@ + + + + + + + + {#if onBendPoint} + + Delete bend point + + {:else} + + Add bend point here + + {/if} + + + Remove all bend points + + + diff --git a/frontend/src/lib/rendering/svelteflow/components/edge/EdgeBendPoints.svelte b/frontend/src/lib/rendering/svelteflow/components/edge/EdgeBendPoints.svelte new file mode 100644 index 00000000..97b47331 --- /dev/null +++ b/frontend/src/lib/rendering/svelteflow/components/edge/EdgeBendPoints.svelte @@ -0,0 +1,133 @@ + + + + + + {#each inactiveBendPoints as inactivePoint (inactivePoint.insertionIndex)} + + activateInactivePoint(inactivePoint.insertionIndex, e)} + /> + {/each} + + {#each bendPoints as bendPoint (bendPoint.id)} + beginDrag(bendPoint.id, e)} + /> + {/each} + + + diff --git a/frontend/src/lib/rendering/svelteflow/components/edge/edgeUtils.ts b/frontend/src/lib/rendering/svelteflow/components/edge/edgeUtils.ts new file mode 100644 index 00000000..55aaadc6 --- /dev/null +++ b/frontend/src/lib/rendering/svelteflow/components/edge/edgeUtils.ts @@ -0,0 +1,267 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import { type InternalNode } from "@xyflow/svelte"; + +export interface BendPoint { + id: string; + x: number; + y: number; +} + +export interface InactiveBendPoint { + x: number; + y: number; + /** Index in the bendPoints array where this point would be inserted when activated. */ + insertionIndex: number; +} + +/** + * Calculates the intersection point between a line (from the target to the node center) + * and the border of the intersection node. Used to determine edge start and end points. + * This and the following methods are adapted from the SvelteFlow "Easy Connect" example. + * See: https://svelteflow.dev/examples/nodes/easy-connect + */ +function getNodeIntersection( + intersectionNode: InternalNode, + targetNode: InternalNode, + offsetY: number = 0, +) { + const intersectionPos = intersectionNode.internals.positionAbsolute || { + x: 0, + y: 0, + }; + const targetPos = targetNode.internals.positionAbsolute || { x: 0, y: 0 }; + + const w = (intersectionNode.measured.width ?? 0) / 2; + const h = (intersectionNode.measured.height ?? 0) / 2; + + const x2 = intersectionPos.x + w; + const y2 = intersectionPos.y + h; + const x1 = targetPos.x + (targetNode.measured.width ?? 0) / 2; + const y1 = targetPos.y + (targetNode.measured.height ?? 0) / 2 + offsetY; + + const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h); + const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h); + + const a = 1 / (Math.abs(xx1) + Math.abs(yy1)); + const xx3 = a * xx1; + const yy3 = a * yy1; + + return { + x: w * (xx3 + yy3) + x2, + y: h * (-xx3 + yy3) + y2, + }; +} + +/** + * Returns all parameters needed to render an edge and its labels. + * Contains the start/end points of the edge as well as the calculated label positions. + * + * When bend points are provided, the source endpoint aims at the first bend point + * and the target endpoint aims at the last bend point, so the edge docks correctly + * on the node borders facing its actual routing instead of the opposite node. + */ +export function getEdgeParams( + source: InternalNode, + target: InternalNode, + offsetY: number = 0, + bendPoints: { x: number; y: number }[] = [], +) { + const sourceAim = + bendPoints.length > 0 ? makePointNode(bendPoints[0]) : target; + const targetAim = + bendPoints.length > 0 + ? makePointNode(bendPoints[bendPoints.length - 1]) + : source; + + const sourceIntersection = getNodeIntersection(source, sourceAim); + const targetIntersection = getNodeIntersection(target, targetAim, offsetY); + + const startAim = + bendPoints.length > 0 + ? bendPoints[0] + : { x: targetIntersection.x, y: targetIntersection.y }; + const endAim = + bendPoints.length > 0 + ? bendPoints[bendPoints.length - 1] + : { x: sourceIntersection.x, y: sourceIntersection.y }; + + const startOffset = getSingleLabelOffset( + sourceIntersection.x, + sourceIntersection.y, + startAim.x, + startAim.y, + ); + const endOffset = getSingleLabelOffset( + targetIntersection.x, + targetIntersection.y, + endAim.x, + endAim.y, + ); + + return { + sx: sourceIntersection.x, + sy: sourceIntersection.y, + tx: targetIntersection.x, + ty: targetIntersection.y, + startX: startOffset.x, + startY: startOffset.y, + endX: endOffset.x, + endY: endOffset.y, + }; +} + +/** + * Wraps a plain point as a minimal InternalNode-like object so it can be used + * as an "aim" target in getNodeIntersection. The point acts as a zero-size node + * centered exactly on the given coordinates. + */ +function makePointNode(point: { x: number; y: number }): InternalNode { + return { + internals: { positionAbsolute: { x: point.x, y: point.y } }, + measured: { width: 0, height: 0 }, + } as unknown as InternalNode; +} + +/** + * Builds a direct (diagonal) polyline SVG path through the given ordered points. + * The points array must already include the source endpoint first and the + * target endpoint last, with any bend points in between. + */ +export function getPolylinePath(points: { x: number; y: number }[]): string { + if (points.length < 2) return ""; + const [start, ...rest] = points; + const segments = rest.map(p => `L ${p.x} ${p.y}`).join(" "); + return `M ${start.x} ${start.y} ${segments}`; +} + +/** + * Computes the midpoint of every segment in the given ordered point list. + * These are the inactive bend points shown as insertion hints on a selected edge. + * `points` must include source first and target last. + */ +export function getInactiveBendPoints( + points: { x: number; y: number }[], +): InactiveBendPoint[] { + const inactiveBendPoints: InactiveBendPoint[] = []; + for (let i = 0; i < points.length - 1; i++) { + inactiveBendPoints.push({ + x: (points[i].x + points[i + 1].x) / 2, + y: (points[i].y + points[i + 1].y) / 2, + insertionIndex: i, + }); + } + return inactiveBendPoints; +} + +/** + * Finds the index in the bend points array at which a new point should be + * inserted so that it lands on the segment closest to the given position. + * + * `orderedPoints` must be the full ordered list [source, ...bendPoints, target]. + * The returned index is relative to the bendPoints array (source excluded), + * i.e. inserting at that index in bendPoints places the new point on the + * closest segment. + */ +export function getClosestSegmentInsertionIndex( + orderedPoints: { x: number; y: number }[], + position: { x: number; y: number }, +): number { + let bestIndex = 0; + let bestDistance = Infinity; + + for (let i = 0; i < orderedPoints.length - 1; i++) { + const distance = distanceToSegment( + position, + orderedPoints[i], + orderedPoints[i + 1], + ); + if (distance < bestDistance) { + bestDistance = distance; + bestIndex = i; + } + } + + return bestIndex; +} + +/** + * Returns the shortest distance from a point to a line segment. + */ +function distanceToSegment( + point: { x: number; y: number }, + segmentStart: { x: number; y: number }, + segmentEnd: { x: number; y: number }, +): number { + const dx = segmentEnd.x - segmentStart.x; + const dy = segmentEnd.y - segmentStart.y; + const lengthSquared = dx * dx + dy * dy; + + if (lengthSquared === 0) { + return Math.hypot(point.x - segmentStart.x, point.y - segmentStart.y); + } + + let t = + ((point.x - segmentStart.x) * dx + (point.y - segmentStart.y) * dy) / + lengthSquared; + t = Math.max(0, Math.min(1, t)); + + const projX = segmentStart.x + t * dx; + const projY = segmentStart.y + t * dy; + return Math.hypot(point.x - projX, point.y - projY); +} + +/** + * Calculates the offset for a single label based on the direction of the + * segment it belongs to. The label is pushed a bit along the segment (away + * from the node) and a bit perpendicular to it, so it sits next to the line. + */ +function getSingleLabelOffset( + fromX: number, + fromY: number, + towardX: number, + towardY: number, +) { + const ALONG_EDGE_DISTANCE = 20; + const PERPENDICULAR_DISTANCE = 14; + + const dx = towardX - fromX; + const dy = towardY - fromY; + const length = Math.sqrt(dx * dx + dy * dy) || 1; + + const normDx = dx / length; + const normDy = dy / length; + + const angle = Math.atan2(-dy, dx) * (180 / Math.PI); + const normalizedAngle = (angle + 360) % 360; + + const labelOnLeft = + (normalizedAngle >= 90 && normalizedAngle < 180) || + normalizedAngle >= 270; + const side = labelOnLeft ? 1 : -1; + + const alongX = normDx * ALONG_EDGE_DISTANCE; + const alongY = normDy * ALONG_EDGE_DISTANCE; + const perpX = -normDy * PERPENDICULAR_DISTANCE * side; + const perpY = normDx * PERPENDICULAR_DISTANCE * side; + + return { + x: alongX + perpX, + y: alongY + perpY, + }; +} \ No newline at end of file diff --git a/frontend/src/lib/rendering/svelteflow/interaction/bendPointOperations.js b/frontend/src/lib/rendering/svelteflow/interaction/bendPointOperations.js new file mode 100644 index 00000000..c5d685a1 --- /dev/null +++ b/frontend/src/lib/rendering/svelteflow/interaction/bendPointOperations.js @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2024-2026 SOPTIM AG + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +export const MAX_BEND_POINTS_PER_EDGE = 20; + +/** + * Returns the bend points of an edge, or an empty array if none exist. + */ +export function getBendPoints(edge) { + return edge?.data?.bendPoints ?? []; +} + +/** + * Returns a new bend points array with a new point inserted at the given index. + */ +export function insertBendPointAt(bendPoints, index, point) { + const newBendPoints = [...bendPoints]; + newBendPoints.splice(index, 0, point); + return newBendPoints; +} + +/** + * Returns a new bend points array without the point with the given id. + */ +export function removeBendPoint(bendPoints, pointId) { + return bendPoints.filter(bp => bp.id !== pointId); +} + +/** + * Creates a fresh bend point object at the given position. + */ +export function createBendPoint(x, y) { + return { id: crypto.randomUUID(), x, y }; +} + +/** + * Finds the first active bend point within the given hit radius of a position. + * Returns the bend point or null. Used to decide whether a click on an edge + * targets an existing bend point, even when the bend points are not rendered. + */ +export function findBendPointAtPosition(bendPoints, position, hitRadius) { + for (const bp of bendPoints) { + const dx = bp.x - position.x; + const dy = bp.y - position.y; + if (Math.hypot(dx, dy) <= hitRadius) { + return bp; + } + } + return null; +} diff --git a/frontend/src/lib/rendering/svelteflow/interaction/contextMenus.svelte.js b/frontend/src/lib/rendering/svelteflow/interaction/contextMenus.svelte.js index 6dbf0804..8b3ff653 100644 --- a/frontend/src/lib/rendering/svelteflow/interaction/contextMenus.svelte.js +++ b/frontend/src/lib/rendering/svelteflow/interaction/contextMenus.svelte.js @@ -17,17 +17,30 @@ import { editorState, multiSelectState } from "$lib/sharedState.svelte.js"; +import { + findBendPointAtPosition, + getBendPoints, +} from "./bendPointOperations.js"; + +/** Base hit radius (in screen pixels) for detecting a bend point under the cursor. */ +const BEND_POINT_HIT_RADIUS_PX = 10; + export class ContextMenuController { #paneRequest = $state(null); #classRequest = $state(null); + #edgeRequest = $state(null); #contextMenuClass = $state(null); #getSvelteFlow; #getIsReadOnly; + #getEdges; + #selectEdge; - constructor({ getSvelteFlow, getIsReadOnly }) { + constructor({ getSvelteFlow, getIsReadOnly, getEdges, selectEdge }) { this.#getSvelteFlow = getSvelteFlow; this.#getIsReadOnly = getIsReadOnly; + this.#getEdges = getEdges ?? (() => []); + this.#selectEdge = selectEdge ?? (() => {}); } get paneRequest() { @@ -38,6 +51,10 @@ export class ContextMenuController { return this.#classRequest; } + get edgeRequest() { + return this.#edgeRequest; + } + get contextMenuClass() { return this.#contextMenuClass; } @@ -45,6 +62,7 @@ export class ContextMenuController { close() { this.#paneRequest = null; this.#classRequest = null; + this.#edgeRequest = null; } #consumeEvent(event) { @@ -90,8 +108,46 @@ export class ContextMenuController { }; } - handleEdgeContextMenu({ event }) { + handleEdgeContextMenu({ event, edge }) { this.#consumeEvent(event); + if (this.#getIsReadOnly()) { + return; + } + + const edgeId = edge?.id; + if (!edgeId) { + return; + } + + this.#selectEdge(edgeId); + + const svelteFlow = this.#getSvelteFlow(); + const flowPosition = svelteFlow + ? svelteFlow.screenToFlowPosition( + { x: event.clientX, y: event.clientY }, + { snapToGrid: false }, + ) + : { x: 0, y: 0 }; + + const currentEdge = this.#getEdges().find(e => e.id === edgeId) ?? edge; + const bendPoints = getBendPoints(currentEdge); + + const zoom = svelteFlow?.getViewport?.().zoom ?? 1; + const hitRadius = BEND_POINT_HIT_RADIUS_PX / (zoom || 1); + const hitBendPoint = findBendPointAtPosition( + bendPoints, + flowPosition, + hitRadius, + ); + + this.#edgeRequest = { + x: event.clientX, + y: event.clientY, + edgeId, + flowPosition, + hitBendPointId: hitBendPoint?.id ?? null, + bendPointCount: bendPoints.length, + }; } handleNodeContextMenu({ event, node }) { diff --git a/frontend/src/lib/rendering/svelteflow/svelteFlowWrapper.svelte b/frontend/src/lib/rendering/svelteflow/svelteFlowWrapper.svelte index c3016d60..abdea2da 100644 --- a/frontend/src/lib/rendering/svelteflow/svelteFlowWrapper.svelte +++ b/frontend/src/lib/rendering/svelteflow/svelteFlowWrapper.svelte @@ -29,22 +29,33 @@ import { BackendConnection } from "$lib/api/backend.js"; import { PUBLIC_BACKEND_URL } from "$lib/config/runtime"; import { eventStack } from "$lib/eventhandling/closeEventManager.svelte.js"; + import SvelteFlowEdgeContextMenu from "$lib/rendering/svelteflow/components/contextmenu/SvelteFlowEdgeContextMenu.svelte"; import { editorState, forceReloadTrigger, multiSelectState, } from "$lib/sharedState.svelte.js"; - import AssociationEdge from "./components/AssociationEdge.svelte"; import ClassNode from "./components/ClassNode.svelte"; - import EdgeMarkers from "./components/EdgeMarkers.svelte"; - import InheritanceEdge from "./components/InheritanceEdge.svelte"; - import SvelteFlowClassContextMenu from "./components/SvelteFlowClassContextMenu.svelte"; - import SvelteFlowPaneContextMenu from "./components/SvelteFlowPaneContextMenu.svelte"; + import SvelteFlowClassContextMenu from "./components/contextmenu/SvelteFlowClassContextMenu.svelte"; + import SvelteFlowPaneContextMenu from "./components/contextmenu/SvelteFlowPaneContextMenu.svelte"; + import AssociationEdge from "./components/edge/AssociationEdge.svelte"; + import EdgeMarkers from "./components/edge/EdgeMarkers.svelte"; + import { + getEdgeParams, + getClosestSegmentInsertionIndex, + } from "./components/edge/edgeUtils.ts"; + import InheritanceEdge from "./components/edge/InheritanceEdge.svelte"; import { decorateEdges, hasDefaultNodeLayout, } from "./diagram/diagramElements.js"; + import { + createBendPoint, + insertBendPointAt, + removeBendPoint, + MAX_BEND_POINTS_PER_EDGE, + } from "./interaction/bendPointOperations.js"; import { ContextMenuController } from "./interaction/contextMenus.svelte.js"; import { DiagramSelectionController } from "./interaction/diagramSelection.svelte.js"; import { NodeOrderController } from "./interaction/nodeOrder.svelte.js"; @@ -77,6 +88,8 @@ const contextMenus = new ContextMenuController({ getSvelteFlow: () => svelteFlowAPI?.svelteFlow, getIsReadOnly: () => isDatasetReadOnly, + getEdges: () => edges, + selectEdge: edgeId => selectOnlyEdge(edgeId), }); const pan = new PanController({ @@ -192,6 +205,24 @@ function onContainerContextMenu(event) { pan.handleContainerContextMenuCapture(event); + if (event.defaultPrevented) { + return; + } + routeBendPointContextMenu(event); + } + + function routeBendPointContextMenu(event) { + const target = event.target; + if (!(target instanceof Element)) return; + + const bendPointCircle = target.closest("[data-edge-id]"); + if (!bendPointCircle) return; + + const edgeId = bendPointCircle.getAttribute("data-edge-id"); + const edge = edges.find(e => e.id === edgeId); + if (!edge) return; + + contextMenus.handleEdgeContextMenu({ event, edge }); } function applyAutoLayoutIfNeeded() { @@ -354,6 +385,75 @@ ); } } + function selectOnlyEdge(edgeId) { + edges = edges.map(edge => ({ + ...edge, + selected: edge.id === edgeId, + })); + } + + function updateEdgeBendPoints(edgeId, newBendPoints) { + edges = edges.map(edge => + edge.id === edgeId + ? { + ...edge, + data: { ...edge.data, bendPoints: newBendPoints }, + } + : edge, + ); + } + + function edgeEndpoints(edge, bendPoints) { + const svelteFlow = svelteFlowAPI?.svelteFlow; + if (!svelteFlow?.getInternalNode) return null; + const sourceNode = svelteFlow.getInternalNode(edge.source); + const targetNode = svelteFlow.getInternalNode(edge.target); + if (!sourceNode || !targetNode) return null; + const params = getEdgeParams(sourceNode, targetNode, 0, bendPoints); + return { + source: { x: params.sx, y: params.sy }, + target: { x: params.tx, y: params.ty }, + }; + } + + function handleEdgeAddBendPoint({ edgeId, flowPosition }) { + const edge = edges.find(e => e.id === edgeId); + if (!edge) return; + const bendPoints = edge.data?.bendPoints ?? []; + if (bendPoints.length >= MAX_BEND_POINTS_PER_EDGE) return; + + const endpoints = edgeEndpoints(edge, bendPoints); + let insertionIndex = bendPoints.length; + if (endpoints) { + const orderedPoints = [ + endpoints.source, + ...bendPoints, + endpoints.target, + ]; + insertionIndex = getClosestSegmentInsertionIndex( + orderedPoints, + flowPosition, + ); + } + + const newBendPoints = insertBendPointAt( + bendPoints, + insertionIndex, + createBendPoint(flowPosition.x, flowPosition.y), + ); + updateEdgeBendPoints(edgeId, newBendPoints); + } + + function handleEdgeDeleteBendPoint({ edgeId, bendPointId }) { + const edge = edges.find(e => e.id === edgeId); + if (!edge) return; + const next = removeBendPoint(edge.data?.bendPoints ?? [], bendPointId); + updateEdgeBendPoints(edgeId, next); + } + + function handleEdgeClearBendPoints({ edgeId }) { + updateEdgeBendPoints(edgeId, []); + } export async function applyELKLayout() { if (!isLoading) isLoading = true; @@ -414,7 +514,7 @@ selectionKey={"Shift"} connectionMode={"loose"} multiSelectionKey={"Shift"} - deleteKeyCode={null} + deleteKey={null} minZoom={0.1} maxZoom={5} > @@ -444,6 +544,14 @@ onSetLayer={e => nodeOrderCtrl.setLayer(e)} onPersistLayer={e => nodeOrderCtrl.persistLayer(e)} /> + contextMenus.close()} + onAddBendPoint={handleEdgeAddBendPoint} + onDeleteBendPoint={handleEdgeDeleteBendPoint} + onClearBendPoints={handleEdgeClearBendPoints} + />