From de4f880ad2d925e15434a68730acfa637abd1c3e Mon Sep 17 00:00:00 2001 From: Henrik Storch Date: Sun, 17 Sep 2023 10:19:15 +0200 Subject: [PATCH 1/3] Add self-referenced edge --- DirectedGraphDemo/graph.json | 1 + Sources/Views/Arrow.swift | 65 ++++++++++++++++++++++++------- Sources/Views/EdgeViewModel.swift | 13 ++++++- Sources/Views/GraphView.swift | 3 +- 4 files changed, 64 insertions(+), 18 deletions(-) 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..2097168 100644 --- a/Sources/Views/Arrow.swift +++ b/Sources/Views/Arrow.swift @@ -1,27 +1,57 @@ import SwiftUI struct Arrow: Shape { - private let pointerLineLength: CGFloat = 30 - private let arrowAngle = CGFloat(Double.pi / 6) + enum Constants { + static let pointerLineLength: CGFloat = 15 + static let arrowAngle = CGFloat(Double.pi / 6) + static let circularAngle: Angle = .degrees(45) + static var circularRadius: CGFloat { pointerLineLength * 2 } + } + 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 = Constants.circularAngle + .degrees(45) + let startPoint = CGPoint( + cos(Constants.circularAngle.radians) * -Constants.circularRadius, + sin(Constants.circularAngle.radians) * -Constants.circularRadius) + + start + + path.addArc( + center: startPoint, + radius: Constants.circularRadius, + startAngle: startAngle, + endAngle: endAngle, + clockwise: true) + angle = (endAngle - .degrees(80)).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 +59,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..d14b4c8 100644 --- a/Sources/Views/GraphView.swift +++ b/Sources/Views/GraphView.swift @@ -53,10 +53,11 @@ 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), - SimpleEdge(source: "2", target: "3", value: 1) + SimpleEdge(source: "2", target: "3", value: 1), ] static var previews: some View { From 1ae1f277f83d8cdba112c532ab122402009b21f9 Mon Sep 17 00:00:00 2001 From: Henrik Storch Date: Sun, 17 Sep 2023 10:39:07 +0200 Subject: [PATCH 2/3] Add self-referencing arrow tests --- Sources/Views/Arrow.swift | 6 +++--- Tests/Views/EdgeViewModelTests.swift | 9 +++++++-- Tests/Views/EdgeViewTest.swift | 12 ++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/Sources/Views/Arrow.swift b/Sources/Views/Arrow.swift index 2097168..0072ae2 100644 --- a/Sources/Views/Arrow.swift +++ b/Sources/Views/Arrow.swift @@ -27,10 +27,10 @@ struct Arrow: Shape { } else { path.move(to: start) let startAngle = Constants.circularAngle - let endAngle = Constants.circularAngle + .degrees(45) + let endAngle = startAngle + .degrees(45) let startPoint = CGPoint( - cos(Constants.circularAngle.radians) * -Constants.circularRadius, - sin(Constants.circularAngle.radians) * -Constants.circularRadius) + cos(startAngle.radians) * -Constants.circularRadius, + sin(startAngle.radians) * -Constants.circularRadius) + start path.addArc( 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 From bb724c27300dba507309e7cc5131cc2edd713bda Mon Sep 17 00:00:00 2001 From: Henrik Storch Date: Sun, 17 Sep 2023 11:07:15 +0200 Subject: [PATCH 3/3] cleanup + reset constants --- Sources/Views/Arrow.swift | 9 +++++---- Sources/Views/GraphView.swift | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/Views/Arrow.swift b/Sources/Views/Arrow.swift index 0072ae2..373bf11 100644 --- a/Sources/Views/Arrow.swift +++ b/Sources/Views/Arrow.swift @@ -2,10 +2,10 @@ import SwiftUI struct Arrow: Shape { enum Constants { - static let pointerLineLength: CGFloat = 15 + static let pointerLineLength: CGFloat = 30 static let arrowAngle = CGFloat(Double.pi / 6) - static let circularAngle: Angle = .degrees(45) - static var circularRadius: CGFloat { pointerLineLength * 2 } + static let circularAngle: Angle = .degrees(90) + static var circularRadius: CGFloat { pointerLineLength * 1.25 } } let start: CGPoint @@ -39,7 +39,8 @@ struct Arrow: Shape { startAngle: startAngle, endAngle: endAngle, clockwise: true) - angle = (endAngle - .degrees(80)).radians + // not quite tangential to endAngle because of curvature + angle = (endAngle - .degrees(75)).radians lineEnd = path.currentPoint ?? start } diff --git a/Sources/Views/GraphView.swift b/Sources/Views/GraphView.swift index d14b4c8..860b283 100644 --- a/Sources/Views/GraphView.swift +++ b/Sources/Views/GraphView.swift @@ -57,7 +57,7 @@ struct GraphView_Previews: PreviewProvider { SimpleEdge(source: "1", target: "2", value: 5), SimpleEdge(source: "1", target: "3", value: 1), SimpleEdge(source: "3", target: "4", value: 2), - SimpleEdge(source: "2", target: "3", value: 1), + SimpleEdge(source: "2", target: "3", value: 1) ] static var previews: some View {