diff --git a/DirectedGraphDemo/graph.json b/DirectedGraphDemo/graph.json index 8452924..f4c61c3 100644 --- a/DirectedGraphDemo/graph.json +++ b/DirectedGraphDemo/graph.json @@ -21,6 +21,7 @@ {"source": "A", "target": "E", "value": 4}, {"source": "A", "target": "F", "value": 4}, {"source": "A", "target": "G", "value": 6}, + {"source": "B", "target": "B", "value": 3}, {"source": "G", "target": "G1", "value": 4}, {"source": "G", "target": "G2", "value": 4}, {"source": "G", "target": "G3", "value": 4}, diff --git a/Sources/Views/Arrow.swift b/Sources/Views/Arrow.swift index 1905d4f..373bf11 100644 --- a/Sources/Views/Arrow.swift +++ b/Sources/Views/Arrow.swift @@ -1,27 +1,58 @@ import SwiftUI struct Arrow: Shape { - private let pointerLineLength: CGFloat = 30 - private let arrowAngle = CGFloat(Double.pi / 6) + enum Constants { + static let pointerLineLength: CGFloat = 30 + static let arrowAngle = CGFloat(Double.pi / 6) + static let circularAngle: Angle = .degrees(90) + static var circularRadius: CGFloat { pointerLineLength * 1.25 } + } + let start: CGPoint - let end: CGPoint + let end: CGPoint? let thickness: CGFloat func path(in rect: CGRect) -> Path { var path = Path() - path.move(to: start) - path.addLine(to: end) - - let delta = end - start - let angle = delta.angle - let arrowLine1 = CGPoint(x: end.x + pointerLineLength * cos(CGFloat(Double.pi) - angle + arrowAngle), - y: end.y - pointerLineLength * sin(CGFloat(Double.pi) - angle + arrowAngle)) - let arrowLine2 = CGPoint(x: end.x + pointerLineLength * cos(CGFloat(Double.pi) - angle - arrowAngle), - y: end.y - pointerLineLength * sin(CGFloat(Double.pi) - angle - arrowAngle)) + let angle: CGFloat + let lineEnd: CGPoint + + if let end { + path.move(to: start) + path.addLine(to: end) + let delta = end - start + angle = delta.angle + lineEnd = end + } else { + path.move(to: start) + let startAngle = Constants.circularAngle + let endAngle = startAngle + .degrees(45) + let startPoint = CGPoint( + cos(startAngle.radians) * -Constants.circularRadius, + sin(startAngle.radians) * -Constants.circularRadius) + + start + + path.addArc( + center: startPoint, + radius: Constants.circularRadius, + startAngle: startAngle, + endAngle: endAngle, + clockwise: true) + // not quite tangential to endAngle because of curvature + angle = (endAngle - .degrees(75)).radians + lineEnd = path.currentPoint ?? start + } + + let arrowLine1 = CGPoint( + x: lineEnd.x + Constants.pointerLineLength * cos(CGFloat(Double.pi) - angle + Constants.arrowAngle), + y: lineEnd.y - Constants.pointerLineLength * sin(CGFloat(Double.pi) - angle + Constants.arrowAngle)) + let arrowLine2 = CGPoint( + x: lineEnd.x + Constants.pointerLineLength * cos(CGFloat(Double.pi) - angle - Constants.arrowAngle), + y: lineEnd.y - Constants.pointerLineLength * sin(CGFloat(Double.pi) - angle - Constants.arrowAngle)) path.move(to: arrowLine1) - path.addLine(to: end) + path.addLine(to: lineEnd) path.addLine(to: arrowLine2) return path.strokedPath(.init(lineWidth: thickness)) @@ -29,10 +60,15 @@ struct Arrow: Shape { } struct Arrow_Previews: PreviewProvider { - static let start = CGPoint(x: 80, y: 80) + static let start = CGPoint(x: 120, y: 160) static let end = CGPoint(x: 300, y: 200) + static let circular = CGPoint(x: 120, y: 400) + static var previews: some View { - Arrow(start: start, end: end, thickness: 4) + ZStack { + Arrow(start: start, end: end, thickness: 4) + Arrow(start: circular, end: nil, thickness: 2) + } } } diff --git a/Sources/Views/EdgeViewModel.swift b/Sources/Views/EdgeViewModel.swift index 5993b30..04f67a8 100644 --- a/Sources/Views/EdgeViewModel.swift +++ b/Sources/Views/EdgeViewModel.swift @@ -24,13 +24,22 @@ final class EdgeViewModel: ObservableObject, Identifiable { } } - var middle: CGPoint { (source.position + target.position) / 2 } + var middle: CGPoint { + guard source.id != target.id else { + return CGPoint( + cos(Arrow.Constants.circularAngle.radians) * -Arrow.Constants.circularRadius, + sin(Arrow.Constants.circularAngle.radians) * -Arrow.Constants.circularRadius) + + start + } + return (source.position + target.position) / 2 + } var start: CGPoint { source.position } - var end: CGPoint { + var end: CGPoint? { + guard source.id != target.id else { return nil } let delta = target.position - start let angle = delta.angle let suppr = CGPoint(x: cos(angle) * (target.size.width + value) * 0.5, y: sin(angle) * (target.size.height + value) * 0.5) diff --git a/Sources/Views/GraphView.swift b/Sources/Views/GraphView.swift index 7bcfe20..860b283 100644 --- a/Sources/Views/GraphView.swift +++ b/Sources/Views/GraphView.swift @@ -53,6 +53,7 @@ struct GraphView_Previews: PreviewProvider { SimpleNode(id: "4", group: 2)] private static let edges = [ + SimpleEdge(source: "1", target: "1", value: 5), SimpleEdge(source: "1", target: "2", value: 5), SimpleEdge(source: "1", target: "3", value: 1), SimpleEdge(source: "3", target: "4", value: 2), diff --git a/Tests/Views/EdgeViewModelTests.swift b/Tests/Views/EdgeViewModelTests.swift index 37bcbff..90da0ea 100644 --- a/Tests/Views/EdgeViewModelTests.swift +++ b/Tests/Views/EdgeViewModelTests.swift @@ -11,8 +11,13 @@ class EdgeViewModelTests: XCTestCase { targetNode.position = CGPoint(25, 40) let viewModel = EdgeViewModel(source: sourceNode, target: targetNode, value: .zero) - - XCTAssertFalse(viewModel.end.x.isNaN) + XCTAssertNotNil(viewModel.end) + XCTAssertFalse(viewModel.end!.x.isNaN) + } + + func testEndIsNilWhenSourceAndTargetAreSameNode() { + let viewModel = EdgeViewModel(source: sourceNode, target: sourceNode, value: .zero) + XCTAssertNil(viewModel.end) } func testSourceChangeTriggerEdgeChange() { diff --git a/Tests/Views/EdgeViewTest.swift b/Tests/Views/EdgeViewTest.swift index 3f943d9..7ac8748 100644 --- a/Tests/Views/EdgeViewTest.swift +++ b/Tests/Views/EdgeViewTest.swift @@ -21,6 +21,18 @@ class EdgeViewTests: XCTestCase { XCTAssertEqual(arrow.end, viewModel.end) } + func testStartAndEndPositionsSelfReferencing() throws { + let position1 = CGPoint(x: 10, y: 20) + nodeViewModel1.position = position1 + let viewModel = EdgeViewModel(source: nodeViewModel1, target: nodeViewModel1, value: 40) + let view = makeView(viewModel) + + let arrow = try view.inspect().zStack().view(Arrow.self, 0).actualView() + + XCTAssertEqual(arrow.start, position1) + XCTAssertEqual(arrow.end, viewModel.end) + } + func testWhenShowValueDisplayText() throws { let viewModel = EdgeViewModel(source: nodeViewModel1, target: nodeViewModel2, value: 40) viewModel.showValue = true