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/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/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..b189e16 100644 --- a/lib/edgerenderer/EdgeRenderer.dart +++ b/lib/edgerenderer/EdgeRenderer.dart @@ -136,4 +136,73 @@ 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 nodeCenter = getNodeCenter(node); + + final anchorRadius = node.size.shortestSide * 0.5; + + final start = 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 controlPoint2 = end + Offset(0, -loopRadius); + + final path = Path() + ..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, start, end); + } + + final metric = metrics.first; + final totalLength = metric.length; + final effectiveArrowLength = arrowLength <= 0 + ? 0.0 + : min(arrowLength, totalLength * 0.3); + final arrowBaseOffset = max(0.0, totalLength - effectiveArrowLength); + final arrowBaseTangent = metric.getTangentForOffset(arrowBaseOffset); + final arrowTipTangent = metric.getTangentForOffset(totalLength); + + return LoopRenderResult( + path, + arrowBaseTangent?.position ?? end, + arrowTipTangent?.position ?? end, + ); + } +} + +class LoopRenderResult { + final Path path; + final Offset arrowBase; + final Offset arrowTip; + + const LoopRenderResult(this.path, this.arrowBase, this.arrowTip); +} diff --git a/lib/layered/SugiyamaAlgorithm.dart b/lib/layered/SugiyamaAlgorithm.dart index 6dc6241..c51b8b7 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); @@ -772,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); @@ -791,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)); @@ -1181,18 +1193,27 @@ class SugiyamaAlgorithm extends Algorithm { void restoreCycle() { graph.nodes.forEach((n) { - if (nodeData[n]!.isReversed) { - nodeData[n]!.reversed.forEach((target) { - final bendPoints = - this.edgeData[graph.getEdgeBetween(target, n)!]!.bendPoints; - graph.removeEdgeFromPredecessor(target, n); - final edge = graph.addEdge(n, target); + final nodeInfo = nodeData[n]; + if (nodeInfo == null || !nodeInfo.isReversed) { + return; + } - final edgeData = SugiyamaEdgeData(); - edgeData.bendPoints = bendPoints; - this.edgeData[edge] = edgeData; - }); + 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(); }); } @@ -1220,8 +1241,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); } } 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..71f4940 100644 --- a/test/graph_test.dart +++ b/test/graph_test.dart @@ -75,5 +75,63 @@ void main() { expect(timeTaken < 100, true); } }); + + 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') + ..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); + 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)); + expect(tangentStart.vector.dx, greaterThan(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', () { + 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); + }); }); -} \ No newline at end of file +}