From 65271af5ad5055138e7b5c9e09e5e5628dab4d1e Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Sun, 5 Oct 2025 20:38:12 -0300 Subject: [PATCH 01/11] Add self-loop edge rendering support Introduces logic to render self-referential edges (loops) in ArrowEdgeRenderer, SugiyamaEdgeRenderer, and TreeEdgeRenderer. Adds buildSelfLoopPath utility and corresponding test coverage for loop path generation. --- .gitignore | 1 + lib/edgerenderer/ArrowEdgeRenderer.dart | 44 +++++++++++++++-- lib/edgerenderer/EdgeRenderer.dart | 63 ++++++++++++++++++++++++- lib/layered/SugiyamaEdgeRenderer.dart | 37 ++++++++++++++- lib/tree/TreeEdgeRenderer.dart | 10 +++- test/graph_test.dart | 19 +++++++- 6 files changed, 165 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 1985397..84fe9c9 100644 --- a/.gitignore +++ b/.gitignore @@ -72,3 +72,4 @@ build/ !**/ios/**/default.mode2v3 !**/ios/**/default.pbxuser !**/ios/**/default.perspectivev3 +AGENTS.md diff --git a/lib/edgerenderer/ArrowEdgeRenderer.dart b/lib/edgerenderer/ArrowEdgeRenderer.dart index efb0822..cd6d0a1 100644 --- a/lib/edgerenderer/ArrowEdgeRenderer.dart +++ b/lib/edgerenderer/ArrowEdgeRenderer.dart @@ -28,6 +28,44 @@ class ArrowEdgeRenderer extends EdgeRenderer { var source = edge.source; var destination = edge.destination; + final currentPaint = (edge.paint ?? paint)..style = PaintingStyle.stroke; + final lineType = _getLineType(destination); + + if (source == destination) { + final loopResult = buildSelfLoopPath( + edge, + arrowLength: noArrow ? 0.0 : ARROW_LENGTH, + ); + + if (loopResult != null) { + drawStyledPath(canvas, loopResult.path, currentPaint, lineType: lineType); + + if (!noArrow) { + final trianglePaint = Paint() + ..color = edge.paint?.color ?? paint.color + ..style = PaintingStyle.fill; + final triangleCentroid = drawTriangle( + canvas, + trianglePaint, + loopResult.arrowBase.dx, + loopResult.arrowBase.dy, + loopResult.arrowTip.dx, + loopResult.arrowTip.dy, + ); + + drawStyledLine( + canvas, + loopResult.arrowBase, + triangleCentroid, + currentPaint, + lineType: lineType, + ); + } + + return; + } + } + var sourceOffset = getNodePosition(source); var destinationOffset = getNodePosition(destination); @@ -46,8 +84,6 @@ class ArrowEdgeRenderer extends EdgeRenderer { destination.width, destination.height); - final currentPaint = edge.paint ?? paint; - if (noArrow) { // Draw line without arrow, respecting line type drawStyledLine( @@ -55,7 +91,7 @@ class ArrowEdgeRenderer extends EdgeRenderer { Offset(clippedLine[0], clippedLine[1]), Offset(clippedLine[2], clippedLine[3]), currentPaint, - lineType: _getLineType(destination), + lineType: lineType, ); } else { var trianglePaint = Paint() @@ -84,7 +120,7 @@ class ArrowEdgeRenderer extends EdgeRenderer { Offset(clippedLine[0], clippedLine[1]), triangleCentroid, currentPaint, - lineType: _getLineType(destination), + lineType: lineType, ); } } diff --git a/lib/edgerenderer/EdgeRenderer.dart b/lib/edgerenderer/EdgeRenderer.dart index e3b69cf..ae7cf80 100644 --- a/lib/edgerenderer/EdgeRenderer.dart +++ b/lib/edgerenderer/EdgeRenderer.dart @@ -136,4 +136,65 @@ abstract class EdgeRenderer { canvas.drawPath(path, paint); paint.strokeWidth = originalStrokeWidth; } -} \ No newline at end of file + + /// Builds a loop path for self-referential edges and returns geometry + /// data that renderers can use to draw arrows or style the segment. + LoopRenderResult? buildSelfLoopPath( + Edge edge, { + double loopPadding = 16.0, + double arrowLength = 12.0, + }) { + if (edge.source != edge.destination) { + return null; + } + + final node = edge.source; + final center = getNodeCenter(node); + final radius = max(node.width, node.height) * 0.5 + loopPadding; + final loopCenter = Offset( + center.dx + node.width * 0.5 + radius, + center.dy, + ); + + final path = Path() + ..moveTo(center.dx + node.width * 0.5, center.dy) + ..arcTo( + Rect.fromCircle(center: loopCenter, radius: radius), + pi, + 1.45 * pi, + false, + ); + + final metrics = path.computeMetrics().toList(); + if (metrics.isEmpty) { + return LoopRenderResult(path, center, center); + } + + final metric = metrics.first; + final totalLength = metric.length; + final effectiveArrowLength = arrowLength <= 0 + ? 0.0 + : min(arrowLength, totalLength * 0.3); + final trimmedLength = max(0.0, totalLength - effectiveArrowLength); + + final trimmedPath = Path() + ..addPath(metric.extractPath(0, trimmedLength), Offset.zero); + + final arrowBaseTangent = metric.getTangentForOffset(trimmedLength); + final arrowTipTangent = metric.getTangentForOffset(totalLength); + + return LoopRenderResult( + trimmedPath, + arrowBaseTangent?.position ?? center, + arrowTipTangent?.position ?? center, + ); + } +} + +class LoopRenderResult { + final Path path; + final Offset arrowBase; + final Offset arrowTip; + + const LoopRenderResult(this.path, this.arrowBase, this.arrowTip); +} diff --git a/lib/layered/SugiyamaEdgeRenderer.dart b/lib/layered/SugiyamaEdgeRenderer.dart index 7bbee84..fb41784 100644 --- a/lib/layered/SugiyamaEdgeRenderer.dart +++ b/lib/layered/SugiyamaEdgeRenderer.dart @@ -30,9 +30,42 @@ class SugiyamaEdgeRenderer extends ArrowEdgeRenderer { ..style = PaintingStyle.fill; } - var currentPaint = edge.paint ?? paint + var currentPaint = (edge.paint ?? paint) ..style = PaintingStyle.stroke; + if (edge.source == edge.destination) { + final loopResult = buildSelfLoopPath( + edge, + arrowLength: addTriangleToEdge ? ARROW_LENGTH : 0.0, + ); + + if (loopResult != null) { + final lineType = nodeData[edge.destination]?.lineType; + drawStyledPath(canvas, loopResult.path, currentPaint, lineType: lineType); + + if (addTriangleToEdge) { + final triangleCentroid = drawTriangle( + canvas, + edgeTrianglePaint ?? trianglePaint, + loopResult.arrowBase.dx, + loopResult.arrowBase.dy, + loopResult.arrowTip.dx, + loopResult.arrowTip.dy, + ); + + drawStyledLine( + canvas, + loopResult.arrowBase, + triangleCentroid, + currentPaint, + lineType: lineType, + ); + } + + return; + } + } + if (hasBendEdges(edge)) { _renderEdgeWithBendPoints(canvas, edge, currentPaint, edgeTrianglePaint ?? trianglePaint); } else { @@ -159,4 +192,4 @@ class SugiyamaEdgeRenderer extends ArrowEdgeRenderer { } } } -} \ No newline at end of file +} diff --git a/lib/tree/TreeEdgeRenderer.dart b/lib/tree/TreeEdgeRenderer.dart index 6f2f79f..cea0237 100644 --- a/lib/tree/TreeEdgeRenderer.dart +++ b/lib/tree/TreeEdgeRenderer.dart @@ -19,6 +19,14 @@ class TreeEdgeRenderer extends EdgeRenderer { var node = edge.source; var child = edge.destination; + if (node == child) { + final loopPath = buildSelfLoopPath(edge, arrowLength: 0.0); + if (loopPath != null) { + drawStyledPath(canvas, loopPath.path, edgePaint, lineType: child.lineType); + } + return; + } + final parentPos = getNodePosition(node); final childPos = getNodePosition(child); @@ -214,4 +222,4 @@ class TreeEdgeRenderer extends EdgeRenderer { ..lineTo(childRightX, childCenterY); } } -} \ No newline at end of file +} diff --git a/test/graph_test.dart b/test/graph_test.dart index 5490c0c..9bfe4f6 100644 --- a/test/graph_test.dart +++ b/test/graph_test.dart @@ -75,5 +75,22 @@ void main() { expect(timeTaken < 100, true); } }); + + test('ArrowEdgeRenderer builds self-loop path', () { + final renderer = ArrowEdgeRenderer(); + final node = Node.Id('self') + ..size = const Size(40, 40) + ..position = const Offset(100, 100); + + final edge = Edge(node, node); + final result = renderer.buildSelfLoopPath(edge); + + expect(result, isNotNull); + + final metrics = result!.path.computeMetrics().toList(); + expect(metrics, isNotEmpty); + expect(metrics.first.length, greaterThan(0)); + expect(result.arrowTip, isNot(equals(const Offset(0, 0)))); + }); }); -} \ No newline at end of file +} From be2f835d2705b46b6cd1eada9e35e15fb095420b Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Sun, 5 Oct 2025 21:25:37 -0300 Subject: [PATCH 02/11] Fix node duplication for self-loop edges in Graph Refactored addEdgeS to prevent adding duplicate nodes when an edge is a self-loop. Added a test to verify that self-loop edges do not result in multiple instances of the same node. --- lib/Graph.dart | 17 ++++++++++++++--- test/graph_test.dart | 11 +++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/lib/Graph.dart b/lib/Graph.dart index a8887c0..eac1a83 100644 --- a/lib/Graph.dart +++ b/lib/Graph.dart @@ -51,20 +51,31 @@ class Graph { void addEdgeS(Edge edge) { var sourceSet = false; var destinationSet = false; - _nodes.forEach((node) { + for (var node in _nodes) { if (!sourceSet && node == edge.source) { edge.source = node; sourceSet = true; - } else if (!destinationSet && node == edge.destination) { + } + + if (!destinationSet && node == edge.destination) { edge.destination = node; destinationSet = true; } - }); + + if (sourceSet && destinationSet) { + break; + } + } if (!sourceSet) { _nodes.add(edge.source); + sourceSet = true; + if (!destinationSet && edge.destination == edge.source) { + destinationSet = true; + } } if (!destinationSet) { _nodes.add(edge.destination); + destinationSet = true; } if (!_edges.contains(edge)) { diff --git a/test/graph_test.dart b/test/graph_test.dart index 9bfe4f6..fce660b 100644 --- a/test/graph_test.dart +++ b/test/graph_test.dart @@ -76,6 +76,17 @@ void main() { } }); + test('Graph does not duplicate nodes for self loops', () { + final graph = Graph(); + final node = Node.Id('self'); + + graph.addEdge(node, node); + + expect(graph.nodes.length, 1); + expect(graph.edges.length, 1); + expect(graph.nodes.single, node); + }); + test('ArrowEdgeRenderer builds self-loop path', () { final renderer = ArrowEdgeRenderer(); final node = Node.Id('self') From 023719dcdba736feb7b72678274528613d97c07e Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Sun, 5 Oct 2025 21:54:47 -0300 Subject: [PATCH 03/11] Refactor loop edge rendering to use cubic Bezier Replaces arc-based loop edge rendering with a cubic Bezier curve for improved control over loop shape. Updates start, end, and control point calculations to use node position and dimensions, enhancing visual consistency and flexibility. --- lib/edgerenderer/EdgeRenderer.dart | 48 +++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/lib/edgerenderer/EdgeRenderer.dart b/lib/edgerenderer/EdgeRenderer.dart index ae7cf80..8d72aa3 100644 --- a/lib/edgerenderer/EdgeRenderer.dart +++ b/lib/edgerenderer/EdgeRenderer.dart @@ -149,25 +149,45 @@ abstract class EdgeRenderer { } final node = edge.source; - final center = getNodeCenter(node); - final radius = max(node.width, node.height) * 0.5 + loopPadding; - final loopCenter = Offset( - center.dx + node.width * 0.5 + radius, - center.dy, + final nodePosition = getNodePosition(node); + + final start = Offset( + nodePosition.dx + node.width, + nodePosition.dy + node.height * 0.5, + ); + + final end = Offset( + nodePosition.dx + node.width * 0.5, + nodePosition.dy, + ); + + final horizontalOffset = max(loopPadding + node.width * 0.4, 24.0); + final verticalOffset = max(loopPadding + node.height * 0.8, 32.0); + + final controlPoint1 = Offset( + start.dx + horizontalOffset, + start.dy - verticalOffset, + ); + + final controlPoint2 = Offset( + end.dx + horizontalOffset * 0.6, + end.dy - verticalOffset, ); final path = Path() - ..moveTo(center.dx + node.width * 0.5, center.dy) - ..arcTo( - Rect.fromCircle(center: loopCenter, radius: radius), - pi, - 1.45 * pi, - false, + ..moveTo(start.dx, start.dy) + ..cubicTo( + controlPoint1.dx, + controlPoint1.dy, + controlPoint2.dx, + controlPoint2.dy, + end.dx, + end.dy, ); final metrics = path.computeMetrics().toList(); if (metrics.isEmpty) { - return LoopRenderResult(path, center, center); + return LoopRenderResult(path, start, end); } final metric = metrics.first; @@ -185,8 +205,8 @@ abstract class EdgeRenderer { return LoopRenderResult( trimmedPath, - arrowBaseTangent?.position ?? center, - arrowTipTangent?.position ?? center, + arrowBaseTangent?.position ?? end, + arrowTipTangent?.position ?? end, ); } } From b0750147370ba96fc71728fec259a5d3f878a102 Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Sun, 5 Oct 2025 22:02:40 -0300 Subject: [PATCH 04/11] Preserve edge data when reversing edges Ensures that edge data, such as bend points, is retained when edges are reversed in the SugiyamaAlgorithm. This prevents loss of associated metadata during edge manipulation and improves graph consistency. --- lib/layered/SugiyamaAlgorithm.dart | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/lib/layered/SugiyamaAlgorithm.dart b/lib/layered/SugiyamaAlgorithm.dart index 6dc6241..666ce91 100644 --- a/lib/layered/SugiyamaAlgorithm.dart +++ b/lib/layered/SugiyamaAlgorithm.dart @@ -90,11 +90,13 @@ class SugiyamaAlgorithm extends Algorithm { } visited.add(node); stack.add(node); - graph.getOutEdges(node).forEach((edge) { + graph.getOutEdges(node).toList().forEach((edge) { final target = edge.destination; if (stack.contains(target)) { + final storedData = edgeData.remove(edge); graph.removeEdge(edge); - graph.addEdge(target, node); + final reversedEdge = graph.addEdge(target, node); + edgeData[reversedEdge] = storedData ?? SugiyamaEdgeData(); nodeData[node]!.reversed.add(target); } else { dfs(target); @@ -1183,14 +1185,19 @@ class SugiyamaAlgorithm extends Algorithm { graph.nodes.forEach((n) { if (nodeData[n]!.isReversed) { nodeData[n]!.reversed.forEach((target) { - final bendPoints = - this.edgeData[graph.getEdgeBetween(target, n)!]!.bendPoints; + final existingEdge = graph.getEdgeBetween(target, n); + if (existingEdge == null) { + return; + } + final existingData = this.edgeData[existingEdge]; + final bendPoints = existingData?.bendPoints ?? []; + this.edgeData.remove(existingEdge); graph.removeEdgeFromPredecessor(target, n); final edge = graph.addEdge(n, target); - final edgeData = SugiyamaEdgeData(); - edgeData.bendPoints = bendPoints; - this.edgeData[edge] = edgeData; + final restoredData = existingData ?? SugiyamaEdgeData(); + restoredData.bendPoints = bendPoints; + this.edgeData[edge] = restoredData; }); } }); @@ -1220,8 +1227,10 @@ class SugiyamaAlgorithm extends Algorithm { for (var edge in feedbackArcs) { var source = edge.source; var target = edge.destination; + final storedData = edgeData.remove(edge); graph.removeEdge(edge); - graph.addEdge(target, source); + final reversedEdge = graph.addEdge(target, source); + edgeData[reversedEdge] = storedData ?? SugiyamaEdgeData(); nodeData[source]!.reversed.add(target); } } From d78a0d927e47cf3dbf7d52d93f506816e45bae9f Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Sun, 5 Oct 2025 22:12:50 -0300 Subject: [PATCH 05/11] Refactor restoreCycle logic in SugiyamaAlgorithm Improved the restoreCycle method by adding null checks, using toList() for safe iteration, and clearing the reversed list after restoration. This enhances code safety and prevents potential runtime errors. --- lib/layered/SugiyamaAlgorithm.dart | 36 +++++++++++++++++------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/lib/layered/SugiyamaAlgorithm.dart b/lib/layered/SugiyamaAlgorithm.dart index 666ce91..797432e 100644 --- a/lib/layered/SugiyamaAlgorithm.dart +++ b/lib/layered/SugiyamaAlgorithm.dart @@ -1183,23 +1183,27 @@ class SugiyamaAlgorithm extends Algorithm { void restoreCycle() { graph.nodes.forEach((n) { - if (nodeData[n]!.isReversed) { - nodeData[n]!.reversed.forEach((target) { - final existingEdge = graph.getEdgeBetween(target, n); - if (existingEdge == null) { - return; - } - final existingData = this.edgeData[existingEdge]; - final bendPoints = existingData?.bendPoints ?? []; - this.edgeData.remove(existingEdge); - graph.removeEdgeFromPredecessor(target, n); - final edge = graph.addEdge(n, target); - - final restoredData = existingData ?? SugiyamaEdgeData(); - restoredData.bendPoints = bendPoints; - this.edgeData[edge] = restoredData; - }); + final nodeInfo = nodeData[n]; + if (nodeInfo == null || !nodeInfo.isReversed) { + return; + } + + for (final target in nodeInfo.reversed.toList()) { + final existingEdge = graph.getEdgeBetween(target, n); + if (existingEdge == null) { + continue; + } + final existingData = this.edgeData.remove(existingEdge); + final bendPoints = existingData?.bendPoints ?? []; + graph.removeEdgeFromPredecessor(target, n); + final edge = graph.addEdge(n, target); + + final restoredData = existingData ?? SugiyamaEdgeData(); + restoredData.bendPoints = bendPoints; + this.edgeData[edge] = restoredData; } + + nodeInfo.reversed.clear(); }); } From b08fd405cce60a964f60a7f595373d4e351b1b88 Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Sun, 5 Oct 2025 22:26:11 -0300 Subject: [PATCH 06/11] Handle empty coordinates and self-loop in SugiyamaAlgorithm Added a check to initialize coordinates when empty and skip empty layers in overlap resolution. Added a test to verify SugiyamaAlgorithm handles a single node with a self-loop without errors. --- lib/layered/SugiyamaAlgorithm.dart | 10 ++++++++++ test/graph_test.dart | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/lib/layered/SugiyamaAlgorithm.dart b/lib/layered/SugiyamaAlgorithm.dart index 797432e..c51b8b7 100644 --- a/lib/layered/SugiyamaAlgorithm.dart +++ b/lib/layered/SugiyamaAlgorithm.dart @@ -774,6 +774,12 @@ class SugiyamaAlgorithm extends Algorithm { break; } + if (coordinates.isEmpty) { + for (final node in graph.nodes) { + coordinates[node] = 0.0; + } + } + // Get the minimum coordinate value var minValue = coordinates.values.reduce(min); @@ -793,6 +799,10 @@ class SugiyamaAlgorithm extends Algorithm { void resolveOverlaps(Map coordinates) { for (var layer in layers) { + if (layer.isEmpty) { + continue; + } + var layerNodes = List.from(layer); layerNodes.sort( (a, b) => nodeData[a]!.position.compareTo(nodeData[b]!.position)); diff --git a/test/graph_test.dart b/test/graph_test.dart index fce660b..7353556 100644 --- a/test/graph_test.dart +++ b/test/graph_test.dart @@ -103,5 +103,22 @@ void main() { expect(metrics.first.length, greaterThan(0)); expect(result.arrowTip, isNot(equals(const Offset(0, 0)))); }); + + test('SugiyamaAlgorithm handles single node self loop', () { + final graph = Graph(); + final node = Node.Id('self') + ..size = const Size(40, 40); + + graph.addEdge(node, node); + + final config = SugiyamaConfiguration() + ..nodeSeparation = 20 + ..levelSeparation = 20; + + final algorithm = SugiyamaAlgorithm(config); + + expect(() => algorithm.run(graph, 0, 0), returnsNormally); + expect(graph.nodes.length, 1); + }); }); } From abb74c624df6ece06858a249563f852dd82bbfab Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Mon, 6 Oct 2025 11:48:21 -0300 Subject: [PATCH 07/11] Adjust self-loop path tangents --- lib/edgerenderer/EdgeRenderer.dart | 13 +++---------- test/graph_test.dart | 13 ++++++++++++- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/edgerenderer/EdgeRenderer.dart b/lib/edgerenderer/EdgeRenderer.dart index 8d72aa3..1eb4b85 100644 --- a/lib/edgerenderer/EdgeRenderer.dart +++ b/lib/edgerenderer/EdgeRenderer.dart @@ -161,18 +161,11 @@ abstract class EdgeRenderer { nodePosition.dy, ); - final horizontalOffset = max(loopPadding + node.width * 0.4, 24.0); - final verticalOffset = max(loopPadding + node.height * 0.8, 32.0); + final loopRadius = max(loopPadding, node.size.shortestSide * 0.5); - final controlPoint1 = Offset( - start.dx + horizontalOffset, - start.dy - verticalOffset, - ); + final controlPoint1 = start + Offset(loopRadius, 0); - final controlPoint2 = Offset( - end.dx + horizontalOffset * 0.6, - end.dy - verticalOffset, - ); + final controlPoint2 = end + Offset(0, -loopRadius); final path = Path() ..moveTo(start.dx, start.dy) diff --git a/test/graph_test.dart b/test/graph_test.dart index 7353556..6e85bc5 100644 --- a/test/graph_test.dart +++ b/test/graph_test.dart @@ -100,8 +100,19 @@ void main() { final metrics = result!.path.computeMetrics().toList(); expect(metrics, isNotEmpty); - expect(metrics.first.length, greaterThan(0)); + final metric = metrics.first; + expect(metric.length, greaterThan(0)); expect(result.arrowTip, isNot(equals(const Offset(0, 0)))); + + final tangentStart = metric.getTangentForOffset(0); + expect(tangentStart, isNotNull); + expect(tangentStart!.vector.dy.abs(), + lessThan(tangentStart.vector.dx.abs() * 0.1)); + + final tangentEnd = metric.getTangentForOffset(metric.length); + expect(tangentEnd, isNotNull); + expect(tangentEnd!.vector.dx.abs(), + lessThan(tangentEnd.vector.dy.abs() * 0.1)); }); test('SugiyamaAlgorithm handles single node self loop', () { From 7e48069fd76b3084c26b2140b73b5d97c09b838a Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Mon, 6 Oct 2025 14:24:47 -0300 Subject: [PATCH 08/11] Orient self-loops in a JFLAP-style arc --- lib/edgerenderer/EdgeRenderer.dart | 15 +++++++++------ test/graph_test.dart | 6 ++++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/edgerenderer/EdgeRenderer.dart b/lib/edgerenderer/EdgeRenderer.dart index 1eb4b85..6ff477e 100644 --- a/lib/edgerenderer/EdgeRenderer.dart +++ b/lib/edgerenderer/EdgeRenderer.dart @@ -152,18 +152,21 @@ abstract class EdgeRenderer { final nodePosition = getNodePosition(node); final start = Offset( - nodePosition.dx + node.width, - nodePosition.dy + node.height * 0.5, + nodePosition.dx + node.width * 0.5, + nodePosition.dy, ); final end = Offset( - nodePosition.dx + node.width * 0.5, - nodePosition.dy, + nodePosition.dx, + nodePosition.dy + node.height * 0.5, ); - final loopRadius = max(loopPadding, node.size.shortestSide * 0.5); + final loopRadius = max( + loopPadding + node.size.shortestSide * 0.25, + node.size.shortestSide * 0.75, + ); - final controlPoint1 = start + Offset(loopRadius, 0); + final controlPoint1 = start + Offset(0, -loopRadius); final controlPoint2 = end + Offset(0, -loopRadius); diff --git a/test/graph_test.dart b/test/graph_test.dart index 6e85bc5..7c79c04 100644 --- a/test/graph_test.dart +++ b/test/graph_test.dart @@ -106,13 +106,15 @@ void main() { final tangentStart = metric.getTangentForOffset(0); expect(tangentStart, isNotNull); - expect(tangentStart!.vector.dy.abs(), - lessThan(tangentStart.vector.dx.abs() * 0.1)); + expect(tangentStart!.vector.dx.abs(), + lessThan(tangentStart.vector.dy.abs() * 0.1)); + expect(tangentStart.vector.dy, lessThan(0)); final tangentEnd = metric.getTangentForOffset(metric.length); expect(tangentEnd, isNotNull); expect(tangentEnd!.vector.dx.abs(), lessThan(tangentEnd.vector.dy.abs() * 0.1)); + expect(tangentEnd.vector.dy, greaterThan(0)); }); test('SugiyamaAlgorithm handles single node self loop', () { From 3cd0c2c70fc2d905e0b6e7b4d1afcd924fdef944 Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Mon, 6 Oct 2025 14:55:21 -0300 Subject: [PATCH 09/11] Make self-loop curve symmetrical --- lib/edgerenderer/EdgeRenderer.dart | 20 ++++++++------------ test/graph_test.dart | 6 +++--- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/lib/edgerenderer/EdgeRenderer.dart b/lib/edgerenderer/EdgeRenderer.dart index 6ff477e..563ee7d 100644 --- a/lib/edgerenderer/EdgeRenderer.dart +++ b/lib/edgerenderer/EdgeRenderer.dart @@ -149,24 +149,20 @@ abstract class EdgeRenderer { } final node = edge.source; - final nodePosition = getNodePosition(node); + final nodeCenter = getNodeCenter(node); - final start = Offset( - nodePosition.dx + node.width * 0.5, - nodePosition.dy, - ); + final anchorRadius = node.size.shortestSide * 0.5; - final end = Offset( - nodePosition.dx, - nodePosition.dy + node.height * 0.5, - ); + final start = nodeCenter + Offset(0, -anchorRadius); + + final end = nodeCenter + Offset(-anchorRadius, 0); final loopRadius = max( - loopPadding + node.size.shortestSide * 0.25, - node.size.shortestSide * 0.75, + loopPadding + anchorRadius, + anchorRadius * 1.5, ); - final controlPoint1 = start + Offset(0, -loopRadius); + final controlPoint1 = start + Offset(-loopRadius, 0); final controlPoint2 = end + Offset(0, -loopRadius); diff --git a/test/graph_test.dart b/test/graph_test.dart index 7c79c04..0d41323 100644 --- a/test/graph_test.dart +++ b/test/graph_test.dart @@ -106,9 +106,9 @@ void main() { final tangentStart = metric.getTangentForOffset(0); expect(tangentStart, isNotNull); - expect(tangentStart!.vector.dx.abs(), - lessThan(tangentStart.vector.dy.abs() * 0.1)); - expect(tangentStart.vector.dy, lessThan(0)); + expect(tangentStart!.vector.dy.abs(), + lessThan(tangentStart.vector.dx.abs() * 0.1)); + expect(tangentStart.vector.dx, lessThan(0)); final tangentEnd = metric.getTangentForOffset(metric.length); expect(tangentEnd, isNotNull); From fa18a65c3c8cee6c957c25c2722e2e8614ebb7ef Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Mon, 6 Oct 2025 15:09:08 -0300 Subject: [PATCH 10/11] Refine self-loop arc geometry --- lib/edgerenderer/EdgeRenderer.dart | 6 +++--- test/graph_test.dart | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/edgerenderer/EdgeRenderer.dart b/lib/edgerenderer/EdgeRenderer.dart index 563ee7d..1ce79e1 100644 --- a/lib/edgerenderer/EdgeRenderer.dart +++ b/lib/edgerenderer/EdgeRenderer.dart @@ -153,16 +153,16 @@ abstract class EdgeRenderer { final anchorRadius = node.size.shortestSide * 0.5; - final start = nodeCenter + Offset(0, -anchorRadius); + final start = nodeCenter + Offset(anchorRadius, 0); - final end = nodeCenter + Offset(-anchorRadius, 0); + final end = nodeCenter + Offset(0, -anchorRadius); final loopRadius = max( loopPadding + anchorRadius, anchorRadius * 1.5, ); - final controlPoint1 = start + Offset(-loopRadius, 0); + final controlPoint1 = start + Offset(loopRadius, 0); final controlPoint2 = end + Offset(0, -loopRadius); diff --git a/test/graph_test.dart b/test/graph_test.dart index 0d41323..71f4940 100644 --- a/test/graph_test.dart +++ b/test/graph_test.dart @@ -108,7 +108,7 @@ void main() { expect(tangentStart, isNotNull); expect(tangentStart!.vector.dy.abs(), lessThan(tangentStart.vector.dx.abs() * 0.1)); - expect(tangentStart.vector.dx, lessThan(0)); + expect(tangentStart.vector.dx, greaterThan(0)); final tangentEnd = metric.getTangentForOffset(metric.length); expect(tangentEnd, isNotNull); From 94e2c0a19601a5da6d684d84fc9b637f9d99e05d Mon Sep 17 00:00:00 2001 From: Thales Matheus <133025183+ThalesMMS@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:51:43 -0300 Subject: [PATCH 11/11] Fix self-loop path to preserve arrow tip geometry --- lib/edgerenderer/EdgeRenderer.dart | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/lib/edgerenderer/EdgeRenderer.dart b/lib/edgerenderer/EdgeRenderer.dart index 1ce79e1..b189e16 100644 --- a/lib/edgerenderer/EdgeRenderer.dart +++ b/lib/edgerenderer/EdgeRenderer.dart @@ -187,16 +187,12 @@ abstract class EdgeRenderer { final effectiveArrowLength = arrowLength <= 0 ? 0.0 : min(arrowLength, totalLength * 0.3); - final trimmedLength = max(0.0, totalLength - effectiveArrowLength); - - final trimmedPath = Path() - ..addPath(metric.extractPath(0, trimmedLength), Offset.zero); - - final arrowBaseTangent = metric.getTangentForOffset(trimmedLength); + final arrowBaseOffset = max(0.0, totalLength - effectiveArrowLength); + final arrowBaseTangent = metric.getTangentForOffset(arrowBaseOffset); final arrowTipTangent = metric.getTangentForOffset(totalLength); return LoopRenderResult( - trimmedPath, + path, arrowBaseTangent?.position ?? end, arrowTipTangent?.position ?? end, );