) {
29 | // Called when the user discards a scene session.
30 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
31 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
32 | }
33 |
34 |
35 | }
36 |
37 |
--------------------------------------------------------------------------------
/DirectedGraphDemo/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "scale" : "1x",
46 | "size" : "20x20"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "scale" : "2x",
51 | "size" : "20x20"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "scale" : "1x",
56 | "size" : "29x29"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "scale" : "2x",
61 | "size" : "29x29"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "scale" : "1x",
66 | "size" : "40x40"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "scale" : "2x",
71 | "size" : "40x40"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "scale" : "1x",
76 | "size" : "76x76"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "scale" : "2x",
81 | "size" : "76x76"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "scale" : "2x",
86 | "size" : "83.5x83.5"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "scale" : "1x",
91 | "size" : "1024x1024"
92 | }
93 | ],
94 | "info" : {
95 | "author" : "xcode",
96 | "version" : 1
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/DirectedGraphDemo/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/DirectedGraphDemo/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/DirectedGraphDemo/ContentView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import DirectedGraph
3 |
4 | struct ContentView: View {
5 | static private let graph = try? SimpleGraph.load(filename: "graph")
6 | private let viewModel = GraphViewModel(Self.graph!)
7 |
8 | var body: some View {
9 | VStack {
10 | GraphView(viewModel)
11 |
12 | HStack {
13 | Button("Release Nodes") {
14 | self.viewModel.releaseNodes()
15 | }
16 |
17 | Spacer()
18 |
19 | Button("Toggle Edge Values") {
20 | self.viewModel.toggleEdgeValues()
21 | }
22 | }.padding()
23 | }
24 | }
25 | }
26 |
27 | struct ContentView_Previews: PreviewProvider {
28 | static var previews: some View {
29 | ContentView()
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/DirectedGraphDemo/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UIApplicationSceneManifest
24 |
25 | UIApplicationSupportsMultipleScenes
26 |
27 | UISceneConfigurations
28 |
29 | UIWindowSceneSessionRoleApplication
30 |
31 |
32 | UISceneConfigurationName
33 | Default Configuration
34 | UISceneDelegateClassName
35 | $(PRODUCT_MODULE_NAME).SceneDelegate
36 |
37 |
38 |
39 |
40 | UILaunchStoryboardName
41 | LaunchScreen
42 | UIRequiredDeviceCapabilities
43 |
44 | armv7
45 |
46 | UISupportedInterfaceOrientations
47 |
48 | UIInterfaceOrientationPortrait
49 | UIInterfaceOrientationLandscapeLeft
50 | UIInterfaceOrientationLandscapeRight
51 |
52 | UISupportedInterfaceOrientations~ipad
53 |
54 | UIInterfaceOrientationPortrait
55 | UIInterfaceOrientationPortraitUpsideDown
56 | UIInterfaceOrientationLandscapeLeft
57 | UIInterfaceOrientationLandscapeRight
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/DirectedGraphDemo/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/DirectedGraphDemo/SceneDelegate.swift:
--------------------------------------------------------------------------------
1 | import UIKit
2 | import SwiftUI
3 |
4 | class SceneDelegate: UIResponder, UIWindowSceneDelegate {
5 | var window: UIWindow?
6 |
7 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
8 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
9 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
10 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
11 |
12 | // Create the SwiftUI view that provides the window contents.
13 | let contentView = ContentView()
14 |
15 | // Use a UIHostingController as window root view controller.
16 | if let windowScene = scene as? UIWindowScene {
17 | let window = UIWindow(windowScene: windowScene)
18 | window.rootViewController = UIHostingController(rootView: contentView)
19 | self.window = window
20 | window.makeKeyAndVisible()
21 | }
22 | }
23 |
24 | func sceneDidDisconnect(_ scene: UIScene) {
25 | // Called as the scene is being released by the system.
26 | // This occurs shortly after the scene enters the background, or when its session is discarded.
27 | // Release any resources associated with this scene that can be re-created the next time the scene connects.
28 | // The scene may re-connect later, as its session was not neccessarily discarded (see `application:didDiscardSceneSessions` instead).
29 | }
30 |
31 | func sceneDidBecomeActive(_ scene: UIScene) {
32 | // Called when the scene has moved from an inactive state to an active state.
33 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive.
34 | }
35 |
36 | func sceneWillResignActive(_ scene: UIScene) {
37 | // Called when the scene will move from an active state to an inactive state.
38 | // This may occur due to temporary interruptions (ex. an incoming phone call).
39 | }
40 |
41 | func sceneWillEnterForeground(_ scene: UIScene) {
42 | // Called as the scene transitions from the background to the foreground.
43 | // Use this method to undo the changes made on entering the background.
44 | }
45 |
46 | func sceneDidEnterBackground(_ scene: UIScene) {
47 | // Called as the scene transitions from the foreground to the background.
48 | // Use this method to save data, release shared resources, and store enough scene-specific state information
49 | // to restore the scene back to its current state.
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/DirectedGraphDemo/graph.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": [
3 | {"id": "A", "group": 1},
4 | {"id": "B", "group": 2},
5 | {"id": "C", "group": 2},
6 | {"id": "D", "group": 2},
7 | {"id": "E", "group": 2},
8 | {"id": "F", "group": 2},
9 | {"id": "G", "group": 2},
10 | {"id": "G1", "group": 3},
11 | {"id": "G2", "group": 3},
12 | {"id": "G3", "group": 3},
13 | {"id": "C1", "group": 3},
14 | {"id": "C2", "group": 3},
15 | {"id": "G31", "group": 4},
16 | ],
17 | "edges": [
18 | {"source": "A", "target": "B", "value": 4},
19 | {"source": "A", "target": "C", "value": 4},
20 | {"source": "A", "target": "D", "value": 4},
21 | {"source": "A", "target": "E", "value": 4},
22 | {"source": "A", "target": "F", "value": 4},
23 | {"source": "A", "target": "G", "value": 6},
24 | {"source": "G", "target": "G1", "value": 4},
25 | {"source": "G", "target": "G2", "value": 4},
26 | {"source": "G", "target": "G3", "value": 4},
27 | {"source": "C", "target": "C1", "value": 2},
28 | {"source": "C", "target": "C2", "value": 2},
29 | {"source": "G3", "target": "G31", "value": 4},
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 nmandica
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Media/Example1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nmandica/DirectedGraph/4aba72560eafae0f778666d0bc08376737dacd9a/Media/Example1.png
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.2
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "DirectedGraph",
8 | platforms: [
9 | .macOS(.v10_15),
10 | .iOS(.v13)
11 | ],
12 | products: [
13 | // Products define the executables and libraries produced by a package, and make them visible to other packages.
14 | .library(
15 | name: "DirectedGraph",
16 | targets: ["DirectedGraph"]),
17 | ],
18 | dependencies: [
19 | // Dependencies declare other packages that this package depends on.
20 | // .package(url: /* package url */, from: "1.0.0"),
21 | ],
22 | targets: [
23 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
24 | // Targets can depend on other targets in this package, and on products in packages which this package depends on.
25 | .target(
26 | name: "DirectedGraph",
27 | dependencies: [],
28 | path: "Sources"),
29 | .testTarget(
30 | name: "DirectedGraphTests",
31 | dependencies: ["DirectedGraph"],
32 | path: "Tests"),
33 | ]
34 | )
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DirectedGraph
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | SwiftUI package for displaying directed graphs.
14 |
15 |
16 |
17 |
18 |
19 | ## Installation
20 |
21 | In Xcode go to `File -> Swift Packages -> Add Package Dependency…` and paste the repo's url: `https://github.com/nmandica/DirectedGraph`
22 |
23 | ## Usage
24 |
25 | Import the package in the file you would like to use it: `import DirectedGraph`
26 |
27 | You can display a graph by adding a `GraphView` to your view.
28 |
29 | ## Minimum Requirements
30 |
31 | | DirectedGraph | Swift | Xcode | Platforms |
32 | |------------------------|-------------|----------------|------------------------------|
33 | | DirectedGraph 0.1 | Swift 5.2 | Xcode 11.0 | iOS 13.0 / macOS 10.15 |
34 |
35 | ## Thanks
36 |
37 | - [ViewInspector](https://github.com/nalexn/ViewInspector)
38 |
39 | ## License
40 |
41 | DirectedGraph is available under the MIT license. See the LICENSE file for more info.
42 |
--------------------------------------------------------------------------------
/Sources/Extensions/ArrayExtensions.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | extension Array {
4 | var countDistinct: Int {
5 | NSSet(array: self).count
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/Sources/Extensions/CGPointExtensions.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension CGPoint {
4 | @inlinable
5 | init(_ x: CGFloat, _ y: CGFloat) {
6 | self.init(x: x, y: y)
7 | }
8 |
9 | @inlinable
10 | static func += (lhs: inout CGPoint, rhs: CGPoint) {
11 | lhs.x += rhs.x
12 | lhs.y += rhs.y
13 | }
14 |
15 | @inlinable
16 | static func + (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
17 | var copy = lhs
18 | copy += rhs
19 | return copy
20 | }
21 |
22 | @inlinable
23 | static prefix func - (point: CGPoint) -> CGPoint {
24 | return CGPoint(-point.x, -point.y)
25 | }
26 |
27 | @inlinable
28 | static func - (lhs: CGPoint, rhs: CGPoint) -> CGPoint {
29 | return lhs + (-rhs)
30 | }
31 |
32 | @inlinable
33 | static func * (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
34 | return CGPoint(lhs.x * rhs, lhs.y * rhs)
35 | }
36 |
37 | @inlinable
38 | static func / (lhs: CGPoint, rhs: CGFloat) -> CGPoint {
39 | return CGPoint(lhs.x / rhs, lhs.y / rhs)
40 | }
41 |
42 | @inlinable
43 | var lengthSquared: CGFloat {
44 | return x * x + y * y
45 | }
46 |
47 | @inlinable
48 | var length: CGFloat {
49 | return lengthSquared.squareRoot()
50 | }
51 |
52 | @inlinable
53 | var angle: CGFloat {
54 | return atan2(y, x)
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/Sources/Extensions/CGRectExtensions.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension CGRect {
4 | @inlinable
5 | var center: CGPoint {
6 | return CGPoint(x: midX, y: midY)
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/Extensions/CGSizeExtensions.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension CGSize {
4 | @inlinable
5 | static func + (lhs: CGSize, rhs: CGSize) -> CGSize {
6 | return CGSize(width: lhs.width + rhs.width, height: lhs.height + rhs.height)
7 | }
8 |
9 | @inlinable
10 | static func / (lhs: CGSize, rhs: CGFloat) -> CGSize {
11 | return CGSize(width: lhs.width / rhs, height: lhs.height / rhs)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/Sources/Extensions/CollectionExtensions.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension Collection where Element == CGPoint {
4 | func averagePoint() -> CGPoint? {
5 | guard !isEmpty else {
6 | return nil
7 | }
8 |
9 | return reduce(.zero, +) / CGFloat(count)
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/Extensions/ComparableExtensions.swift:
--------------------------------------------------------------------------------
1 | extension Comparable {
2 | func clamped(to limits: ClosedRange) -> Self {
3 | return min(max(self, limits.lowerBound), limits.upperBound)
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/Sources/Graph/Edge.swift:
--------------------------------------------------------------------------------
1 | /// An edge is the directed link between two nodes
2 | public protocol Edge: Codable {
3 | var source: String { get }
4 | var target: String { get }
5 | var value: Int { get }
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/Graph/Graph.swift:
--------------------------------------------------------------------------------
1 | /// A graph is a collection of nodes and edges between the nodes
2 | public protocol Graph: Codable {
3 | associatedtype NodeType: Node
4 | associatedtype EdgeType: Edge
5 |
6 | var nodes: [NodeType] { get }
7 | var edges: [EdgeType] { get }
8 | }
9 |
--------------------------------------------------------------------------------
/Sources/Graph/Node.swift:
--------------------------------------------------------------------------------
1 | /// A node represents a vertex of the graph (a dot)
2 | public protocol Node: Codable {
3 | var id: String { get }
4 | }
5 |
--------------------------------------------------------------------------------
/Sources/Graph/SimpleEdge.swift:
--------------------------------------------------------------------------------
1 | public struct SimpleEdge: Edge {
2 | public var source: String
3 | public var target: String
4 | public var value: Int
5 |
6 | public init(source: String, target: String, value: Int) {
7 | self.source = source
8 | self.target = target
9 | self.value = value
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/Graph/SimpleGraph.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct SimpleGraph: Graph {
4 | public var nodes: [SimpleNode]
5 | public var edges: [SimpleEdge]
6 |
7 | public init(nodes: [SimpleNode], edges: [SimpleEdge]) {
8 | self.nodes = nodes
9 | self.edges = edges
10 | }
11 | }
12 |
13 | extension SimpleGraph {
14 | enum Error: Swift.Error {
15 | case fileNotFound(String)
16 | }
17 |
18 | private init(jsonData: Data) throws {
19 | let decoder = JSONDecoder()
20 | let decoded = try decoder.decode(Self.self, from: jsonData)
21 | self.init(nodes: decoded.nodes, edges: decoded.edges)
22 | }
23 |
24 | /**
25 | Create a ```Graph``` from a JSON file.
26 |
27 | # Example
28 | If you want to load the _graph.json_ file
29 | ```
30 | let graph = try! SimpleGraph.load(filename: "graph")
31 | ```
32 | */
33 | public static func load(filename: String, bundle: Bundle = Bundle.main) throws -> Self {
34 | guard let url = bundle.url(forResource: filename, withExtension: "json") else {
35 | throw Error.fileNotFound(filename)
36 | }
37 |
38 | let data = try Data(contentsOf: url)
39 | return try Self(jsonData: data)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Sources/Graph/SimpleNode.swift:
--------------------------------------------------------------------------------
1 | public struct SimpleNode: Node {
2 | public var id: String
3 | public var group: Int
4 |
5 | public init(id: String, group: Int) {
6 | self.id = id
7 | self.group = group
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/Sources/Layouts/CircularLayoutEngine.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A layout engine giving a circular graph
4 | public struct CircularLayoutEngine: LayoutEngine {
5 | public let isIncremental = false
6 |
7 | public init() { }
8 |
9 | public func layout(from layout: Layout, canvas: CGRect, edgeIndices: [[Int]]) -> Layout {
10 | let count = layout.itemCount
11 | let radius = min(canvas.width, canvas.height) * 0.4
12 | let center = canvas.center
13 | let delta = 2 * CGFloat.pi / CGFloat(count)
14 |
15 | var angle = CGFloat(0)
16 | let items = (0.. LayoutItem in
17 | let position = center + CGPoint(cos(angle), sin(angle)) * radius
18 | angle += delta
19 | return LayoutItem(position: position, velocity: CGPoint.zero)
20 | }
21 |
22 | return Layout(items: items)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Sources/Layouts/ForceDirectedLayoutEngine.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A layout engine giving a force-directed graph
4 | public struct ForceDirectedLayoutEngine: LayoutEngine {
5 | private let steps = 3
6 | public let isIncremental = true
7 | public var viscosity: CGFloat = 20
8 | public var friction: CGFloat = 0.7
9 | public var springLength: CGFloat = 70
10 | public var stiffness: CGFloat = 0.09
11 | public var charge: CGFloat = 50
12 | public var gravitationalConstant: CGFloat = 20
13 | public var shieldDistanceSquared: CGFloat = 250000
14 |
15 | public init() { }
16 |
17 | public func layout(from layout: Layout, canvas: CGRect, edgeIndices: [[Int]]) -> Layout {
18 | var positions = layout.items.map { $0.position }
19 | var velocities = layout.items.map { $0.velocity }
20 | for _ in 1...steps {
21 | var forces = Array(repeating: CGPoint.zero, count: layout.itemCount)
22 | let edges = edgeIndices.map { $0.map { positions[$0] } }
23 | let center = canvas.center - (positions.averagePoint() ?? .zero)
24 | for (index, position) in positions.enumerated() {
25 | forces[index] += repulsionForce(at: position, from: positions, skipIndex: index)
26 | forces[index] += springForce(at: position, from: edges[index])
27 | forces[index] += centralForce(at: position, from: canvas.center)
28 |
29 | let nv = velocities[index] + forces[index]
30 | let d = nv.length
31 |
32 | velocities[index] = d > viscosity ? nv / d * viscosity : nv * friction
33 | positions[index] += center + velocities[index]
34 | }
35 | }
36 |
37 | let items = Array(zip(positions, velocities))
38 | return Layout(items: items)
39 | }
40 |
41 | private func springForce(at source: CGPoint, from targets: [CGPoint]) -> CGPoint {
42 | var force = CGPoint.zero
43 | for target in targets {
44 | force += springForce(at: source, from: target)
45 | }
46 |
47 | return force
48 | }
49 |
50 | private func springForce(at source: CGPoint, from target: CGPoint) -> CGPoint {
51 | let delta = target - source
52 | let length = delta.length
53 | let normalized = length > 0 ? delta / length : .zero
54 | return normalized * (length - springLength) * stiffness
55 | }
56 |
57 | private func repulsionForce(at point: CGPoint, from others: [CGPoint], skipIndex skippedIndex: Int) -> CGPoint {
58 | var force = CGPoint.zero
59 | for (index, other) in others.enumerated() {
60 | guard index != skippedIndex else { continue }
61 |
62 | let diff = point - other
63 | let diffSquared = diff.lengthSquared
64 | guard diffSquared < shieldDistanceSquared else {
65 | continue
66 | }
67 | force += diff / (diffSquared + 0.00000001) * charge
68 | }
69 |
70 | return force
71 | }
72 |
73 | private func centralForce(at point: CGPoint, from center: CGPoint) -> CGPoint {
74 | let diff = center - point
75 | let dist = diff.lengthSquared
76 | return dist > shieldDistanceSquared ? diff / dist * gravitationalConstant : .zero
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/Sources/Layouts/Layout.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct Layout {
4 | let items: [LayoutItem]
5 | let itemCount: Int
6 |
7 | init(items: [LayoutItem]) {
8 | self.items = items
9 | self.itemCount = items.count
10 | }
11 |
12 | init(items: [(position: CGPoint, velocity: CGPoint)]) {
13 | self.init(items: items.map {
14 | LayoutItem(position: $0.position, velocity: $0.velocity)
15 | })
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/Sources/Layouts/LayoutEngine.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A way to compute a graph layout
4 | public protocol LayoutEngine {
5 | var isIncremental: Bool { get }
6 |
7 | func layout(from layout: Layout,
8 | canvas: CGRect,
9 | edgeIndices: [[Int]]) -> Layout
10 | }
11 |
--------------------------------------------------------------------------------
/Sources/Layouts/LayoutItem.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct LayoutItem {
4 | let position: CGPoint
5 | let velocity: CGPoint
6 | }
7 |
--------------------------------------------------------------------------------
/Sources/Layouts/RandomLayoutEngine.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | /// A layout engine giving a random graph layout
4 | public struct RandomLayoutEngine: LayoutEngine {
5 | public let isIncremental = false
6 |
7 | public init() { }
8 |
9 | public func layout(from layout: Layout, canvas: CGRect, edgeIndices: [[Int]]) -> Layout {
10 | let count = layout.itemCount
11 | let items = (0.. LayoutItem in
12 | let position = CGPoint(
13 | CGFloat.random(in: 0.. Color {
14 | return colors[index % colorCount]
15 | }
16 |
17 | private static func buildColors(_ count: Int) -> [Color] {
18 | return (0.. Color {
22 | return Angle(radians: Double(index) / Double(colorCount) * 2.0 * .pi).color
23 | }
24 | }
25 |
26 | extension Angle {
27 | var color: Color {
28 | Color(hue: self.radians / (2 * .pi), saturation: 1, brightness: 0.8)
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/Sources/Utilities/Screen.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | class Screen {
4 | public static var bounds: CGRect {
5 | #if canImport(UIKit)
6 | return UIScreen.main.bounds
7 | #else
8 | return NSScreen.main?.frame ?? CGRect()
9 | #endif
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/Sources/Utilities/SizeReader.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | extension View {
4 | func sizeReader(_ size: Binding) -> some View {
5 | SizeReader(size: size) {
6 | self
7 | }
8 | }
9 | }
10 |
11 | struct SizeReader: View {
12 | @Binding var size: CGSize
13 | let content: () -> Content
14 | var body: some View {
15 | ZStack {
16 | content()
17 | .background(
18 | GeometryReader { proxy in
19 | Color.clear
20 | .preference(key: SizePreferenceKey.self, value: proxy.size)
21 | }
22 | )
23 | }
24 | .onPreferenceChange(SizePreferenceKey.self) { preferences in
25 | self.size = preferences
26 | }
27 | }
28 | }
29 |
30 | private struct SizePreferenceKey: PreferenceKey {
31 | typealias Value = CGSize
32 | static var defaultValue: Value = .zero
33 |
34 | static func reduce(value _: inout Value, nextValue: () -> Value) {
35 | _ = nextValue()
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/Views/Arrow.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct Arrow: Shape {
4 | private let pointerLineLength: CGFloat = 30
5 | private let arrowAngle = CGFloat(Double.pi / 6)
6 | let start: CGPoint
7 | let end: CGPoint
8 | let thickness: CGFloat
9 |
10 | func path(in rect: CGRect) -> Path {
11 | var path = Path()
12 |
13 | path.move(to: start)
14 | path.addLine(to: end)
15 |
16 | let delta = end - start
17 | let angle = delta.angle
18 | let arrowLine1 = CGPoint(x: end.x + pointerLineLength * cos(CGFloat(Double.pi) - angle + arrowAngle),
19 | y: end.y - pointerLineLength * sin(CGFloat(Double.pi) - angle + arrowAngle))
20 | let arrowLine2 = CGPoint(x: end.x + pointerLineLength * cos(CGFloat(Double.pi) - angle - arrowAngle),
21 | y: end.y - pointerLineLength * sin(CGFloat(Double.pi) - angle - arrowAngle))
22 |
23 | path.move(to: arrowLine1)
24 | path.addLine(to: end)
25 | path.addLine(to: arrowLine2)
26 |
27 | return path.strokedPath(.init(lineWidth: thickness))
28 | }
29 | }
30 |
31 | struct Arrow_Previews: PreviewProvider {
32 | static let start = CGPoint(x: 80, y: 80)
33 | static let end = CGPoint(x: 300, y: 200)
34 |
35 | static var previews: some View {
36 | Arrow(start: start, end: end, thickness: 4)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/Sources/Views/DefaultNodeView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct DefaultNodeView: View {
4 | @State private var size: CGSize = .zero
5 | private let id: String
6 | private let color: Color
7 |
8 | init(node: SimpleNode, palette: Palette) {
9 | id = node.id
10 | color = palette.color(for: node.group)
11 | }
12 |
13 | public var body: some View {
14 | ZStack {
15 | Ellipse()
16 | .foregroundColor(color)
17 | .frame(width: max(size.width, size.height), height: size.height)
18 |
19 | Text(id)
20 | .padding(10)
21 | .sizeReader($size)
22 | .colorInvert()
23 | .shadow(radius: 1)
24 | }
25 | }
26 | }
27 |
28 | struct DefaultNodeView_Previews: PreviewProvider {
29 | static var previews: some View {
30 | DefaultNodeView(node: SimpleNode(id: "A2", group: 0), palette: Palette(colorCount: 1))
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/Sources/Views/EdgeView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import Combine
3 |
4 | struct EdgeView: View {
5 | @ObservedObject var viewModel: EdgeViewModel
6 |
7 | var body: some View {
8 | ZStack {
9 | Arrow(start: viewModel.start, end: viewModel.end, thickness: viewModel.value)
10 | .foregroundColor(.gray)
11 | .opacity(0.5)
12 |
13 | if viewModel.showValue {
14 | Text(viewModel.value.description)
15 | .font(.caption)
16 | .position(viewModel.middle)
17 | }
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Sources/Views/EdgeViewModel.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import Combine
3 |
4 | final class EdgeViewModel: ObservableObject, Identifiable {
5 | let id: String
6 | let value: CGFloat
7 | @Published private var source: NodeViewModel
8 | @Published private var target: NodeViewModel
9 | @Published var showValue = false
10 | var sourceCancellable: AnyCancellable?
11 | var targetCancellable: AnyCancellable?
12 |
13 | init(source: NodeViewModel, target: NodeViewModel, value: CGFloat) {
14 | self.id = "\(source.id)-\(target.id)"
15 | self.source = source
16 | self.target = target
17 | self.value = value
18 |
19 | sourceCancellable = source.objectWillChange.sink { (_) in
20 | self.objectWillChange.send()
21 | }
22 | targetCancellable = target.objectWillChange.sink { (_) in
23 | self.objectWillChange.send()
24 | }
25 | }
26 |
27 | var middle: CGPoint { (source.position + target.position) / 2 }
28 |
29 | var start: CGPoint {
30 | source.position
31 | }
32 |
33 | var end: CGPoint {
34 | let delta = target.position - start
35 | let angle = delta.angle
36 | let suppr = CGPoint(x: cos(angle) * (target.size.width + value) * 0.5, y: sin(angle) * (target.size.height + value) * 0.5)
37 | return target.position - suppr
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Sources/Views/GraphView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | public struct GraphView: View {
4 | @ObservedObject private var viewModel: GraphViewModel
5 | @State private var currentOffset = CGSize.zero
6 | @State private var finalOffset = CGSize.zero
7 | @State private var scale: CGFloat = 1
8 | private let nodeContent: (Graph.NodeType) -> NodeContent
9 |
10 | public init(_ viewModel: GraphViewModel, @ViewBuilder nodeContent: @escaping (Graph.NodeType) -> NodeContent) {
11 | self.viewModel = viewModel
12 | self.nodeContent = nodeContent
13 | }
14 |
15 | public var body: some View {
16 | let offset = finalOffset + currentOffset
17 | let scroll = DragGesture()
18 | .onChanged { gesture in
19 | self.currentOffset = gesture.translation / self.scale
20 | }
21 | .onEnded { _ in
22 | self.finalOffset = offset
23 | self.currentOffset = CGSize.zero
24 | }
25 |
26 | return ZStack {
27 | ForEach(viewModel.edges) { edge in
28 | EdgeView(viewModel: edge)
29 | }
30 |
31 | ForEach(viewModel.nodes) { node in
32 | NodeView(viewModel: node) {
33 | self.nodeContent((node.node as? Graph.NodeType)!)
34 | }
35 | }
36 | }
37 | .offset(offset)
38 | .scaleEffect(scale)
39 | .contentShape(Rectangle())
40 | .gesture(scroll)
41 | .scalable(initialScale: self.$scale, scaleRange: CGFloat(0.2)...5)
42 | .onAppear {
43 | self.viewModel.startLayout()
44 | }
45 | }
46 | }
47 |
48 | struct GraphView_Previews: PreviewProvider {
49 | private static let nodes = [
50 | SimpleNode(id: "1", group: 0),
51 | SimpleNode(id: "2", group: 0),
52 | SimpleNode(id: "3", group: 1),
53 | SimpleNode(id: "4", group: 2)]
54 |
55 | private static let edges = [
56 | SimpleEdge(source: "1", target: "2", value: 5),
57 | SimpleEdge(source: "1", target: "3", value: 1),
58 | SimpleEdge(source: "3", target: "4", value: 2),
59 | SimpleEdge(source: "2", target: "3", value: 1)
60 | ]
61 |
62 | static var previews: some View {
63 | GraphView(GraphViewModel(SimpleGraph(nodes: nodes,
64 | edges: edges)))
65 | }
66 | }
67 |
68 | public extension GraphView where NodeContent == DefaultNodeView, Graph == SimpleGraph {
69 | init(_ viewModel: GraphViewModel) {
70 | let count = viewModel.graphNodes.compactMap { $0.group }.countDistinct
71 | let palette = Palette(colorCount: count)
72 | self.init(viewModel) { node in
73 | DefaultNodeView(node: node, palette: palette)
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/Sources/Views/GraphViewModel.swift:
--------------------------------------------------------------------------------
1 | import Combine
2 | import SwiftUI
3 |
4 | final public class GraphViewModel: ObservableObject {
5 | private var timer: Timer?
6 | private var isSimulating: Bool { timer != nil }
7 | private var edgeIndices: [[Int]]
8 | private(set) var nodes: [NodeViewModel]
9 | private(set) var edges: [EdgeViewModel]
10 | let graphNodes: [Graph.NodeType]
11 | var willChange = PassthroughSubject()
12 |
13 | public var layoutEngine: LayoutEngine = ForceDirectedLayoutEngine()
14 |
15 | public init(_ graph: Graph) {
16 | nodes = Self.buildNodes(graph)
17 | edges = Self.buildEdges(graph.edges, nodeViewModels: nodes)
18 | graphNodes = graph.nodes
19 | edgeIndices = Self.buildEdgeIndices(graph)
20 | }
21 |
22 | public func releaseNodes() {
23 | for node in nodes {
24 | node.interactive = false
25 | }
26 | }
27 |
28 | public func toggleEdgeValues() {
29 | for edge in edges {
30 | edge.showValue.toggle()
31 | }
32 | }
33 |
34 | public func toggleAutoLayout() {
35 | isSimulating ? stopLayout() : startLayout()
36 | }
37 |
38 | private static func buildNodes(_ graph: Graph) -> [NodeViewModel] {
39 | return graph.nodes.map { NodeViewModel($0) }
40 | }
41 |
42 | private static func buildEdges(_ edges: [Edge], nodeViewModels: [NodeViewModel]) -> [EdgeViewModel] {
43 | let nodeViewModelLookup = Dictionary(uniqueKeysWithValues: nodeViewModels.map { ($0.id, $0) })
44 | let viewModels: [EdgeViewModel] = edges.compactMap {
45 | guard let source = nodeViewModelLookup[$0.source],
46 | let target = nodeViewModelLookup[$0.target] else {
47 | return nil
48 | }
49 | return EdgeViewModel(source: source, target: target, value: CGFloat($0.value))
50 | }
51 |
52 | return viewModels
53 | }
54 |
55 | private static func buildEdgeIndices(_ graph: Graph) -> [[Int]] {
56 | let nodeIndexLookup = Dictionary(uniqueKeysWithValues: graph.nodes.enumerated().map { ($0.1.id, $0.0) })
57 | var edgeIndices = Array(repeating: [Int](), count: graph.nodes.count)
58 |
59 | for edge in graph.edges {
60 | guard let a = nodeIndexLookup[edge.source],
61 | let b = nodeIndexLookup[edge.target] else {
62 | continue
63 | }
64 | edgeIndices[a].append(b)
65 | edgeIndices[b].append(a)
66 | }
67 |
68 | return edgeIndices
69 | }
70 |
71 | public func startLayout() {
72 | guard layoutEngine.isIncremental else {
73 | self.computeLayout(engine: layoutEngine)
74 | return
75 | }
76 |
77 | guard !isSimulating else { return }
78 |
79 | timer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in
80 | guard let self = self else { return }
81 | self.willChange.send()
82 | self.computeLayout(engine: self.layoutEngine)
83 | }
84 | RunLoop.main.add(timer!, forMode: .common)
85 | }
86 |
87 | public func stopLayout() {
88 | guard isSimulating else { return }
89 |
90 | timer?.invalidate()
91 | timer = nil
92 | }
93 |
94 | private func computeLayout(engine: LayoutEngine) {
95 | let currentLayout = Layout(items: nodes.map {
96 | LayoutItem(position: $0.position, velocity: $0.velocity)
97 | })
98 |
99 | let layout = engine.layout(from: currentLayout,
100 | canvas: Screen.bounds,
101 | edgeIndices: edgeIndices)
102 |
103 | for (index, item) in layout.items.enumerated() {
104 | guard !nodes[index].interactive else { continue }
105 | nodes[index].position = item.position
106 | nodes[index].velocity = item.velocity
107 | }
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/Sources/Views/NodeView.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import SwiftUI
3 |
4 | struct NodeView: View {
5 | @ObservedObject private var viewModel: NodeViewModel
6 | private let content: () -> Content
7 | @State private var delta: CGPoint = .zero
8 |
9 | public init(viewModel: NodeViewModel, @ViewBuilder content: @escaping () -> Content) {
10 | self.viewModel = viewModel
11 | self.content = content
12 | }
13 |
14 | var body: some View {
15 | content()
16 | .sizeReader($viewModel.size)
17 | .position(viewModel.position)
18 | .gesture(drag)
19 | .onTapGesture(count: 2) {
20 | self.viewModel.interactive.toggle()
21 | }
22 | }
23 |
24 | private var drag: some Gesture {
25 | DragGesture()
26 | .onChanged { value in
27 | if self.delta == .zero {
28 | self.delta = value.location - self.viewModel.position
29 | }
30 | self.viewModel.interactive = true
31 | self.viewModel.position = value.location - self.delta
32 | self.viewModel.velocity = .zero
33 | }
34 | .onEnded { _ in
35 | self.viewModel.interactive = true
36 | self.delta = .zero
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Sources/Views/NodeViewModel.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import Combine
3 |
4 | final class NodeViewModel: ObservableObject, Identifiable {
5 | let node: Node
6 | let id: String
7 | @Published var interactive = false
8 | @Published var position: CGPoint
9 | @Published var size: CGSize
10 | var velocity: CGPoint
11 |
12 | init(_ node: Node) {
13 | self.node = node
14 | id = node.id
15 | position = .zero
16 | velocity = .zero
17 | size = .zero
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Sources/Views/ScalableView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct ScalableView: View {
4 | @State private var currentAmount: CGFloat = 0
5 | @Binding private var finalAmount: CGFloat
6 | let scaleRange: ClosedRange
7 | let content: () -> Content
8 |
9 | var body: some View {
10 | content()
11 | .scaleEffect(1 + currentAmount / finalAmount)
12 | .gesture(
13 | TapGesture(count: 2)
14 | .onEnded({ _ in
15 | withAnimation {
16 | self.finalAmount = self.finalAmount != 1.0 ? 1.0 : 2.0
17 | }
18 | }))
19 | .gesture(
20 | MagnificationGesture()
21 | .onChanged { amount in
22 | if self.finalAmount + amount - 1 >= 0 {
23 | self.currentAmount = amount - 1
24 | } else {
25 | self.currentAmount = -self.finalAmount
26 | }
27 | }
28 | .onEnded { _ in
29 | self.finalAmount = (self.finalAmount + self.currentAmount).clamped(to: self.scaleRange)
30 | self.currentAmount = 0
31 | }
32 | )
33 | }
34 |
35 | public init(initialScale: Binding, scaleRange: ClosedRange, @ViewBuilder content: @escaping () -> Content) {
36 | self._finalAmount = initialScale
37 | self.scaleRange = scaleRange
38 | self.content = content
39 | }
40 | }
41 |
42 | extension View {
43 | func scalable(initialScale: Binding, scaleRange: ClosedRange = CGFloat(0.5)...10.0) -> some View {
44 | ScalableView(initialScale: initialScale, scaleRange: scaleRange) {
45 | self
46 | }
47 | }
48 | }
49 |
50 | struct ScalableView_Previews: PreviewProvider {
51 | @State static var scale: CGFloat = 1
52 | static var previews: some View {
53 | ScalableView(initialScale: $scale, scaleRange: CGFloat(0.2)...4.0) {
54 | Text("Test Text")
55 | }
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Tests/DirectedGraphTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(DirectedGraphTests.allTests),
7 | ]
8 | }
9 | #endif
10 |
--------------------------------------------------------------------------------
/Tests/Extensions/ArrayExtensionsTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DirectedGraph
3 |
4 | class ArrayExtensionsTests: XCTestCase {
5 | override func setUpWithError() throws {
6 | // Put setup code here. This method is called before the invocation of each test method in the class.
7 | }
8 |
9 | override func tearDownWithError() throws {
10 | // Put teardown code here. This method is called after the invocation of each test method in the class.
11 | }
12 |
13 | func testCountDistinct() throws {
14 | let array = [1, 8, 92, 1, 1, 3, 2, 90, 92, -1]
15 |
16 | let count = array.countDistinct
17 |
18 | XCTAssertEqual(count, 7)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/Tests/Extensions/CGPointExtensionsTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DirectedGraph
3 |
4 | class CGPointExtensionsTests: XCTestCase {
5 | func testAddition() {
6 | let point1 = CGPoint(x: 40, y: 121)
7 | let point2 = CGPoint(x: 50, y: 32)
8 |
9 | let added = point1 + point2
10 |
11 | XCTAssertEqual(added, CGPoint(x: 90, y: 153))
12 | }
13 |
14 | func testSubtraction() {
15 | let point1 = CGPoint(x: 50, y: 1)
16 | let point2 = CGPoint(x: 10, y: 32)
17 |
18 | let substracted = point1 - point2
19 |
20 | XCTAssertEqual(substracted, CGPoint(x: 40, y: -31))
21 | }
22 |
23 | func testOpposite() {
24 | let point = CGPoint(x: 50, y: -21)
25 |
26 | let opposite = -point
27 |
28 | XCTAssertEqual(opposite, CGPoint(x: -50, y: 21))
29 | }
30 |
31 | func testAdditionCompoundAssignment() {
32 | var point1 = CGPoint(x: 40, y: 121)
33 | let point2 = CGPoint(x: 50, y: 32)
34 |
35 | point1 += point2
36 |
37 | XCTAssertEqual(point1, CGPoint(x: 90, y: 153))
38 | }
39 |
40 | func testFloatMultiplication() {
41 | let point = CGPoint(x: 50, y: 2)
42 |
43 | let multiplied = point * 3
44 |
45 | XCTAssertEqual(multiplied, CGPoint(x: 150, y: 6))
46 | }
47 |
48 | func testFloatDivision() {
49 | let point = CGPoint(x: 50, y: 2)
50 |
51 | let divided = point / 2
52 |
53 | XCTAssertEqual(divided, CGPoint(x: 25, y: 1))
54 | }
55 |
56 | func testLengthSquared() {
57 | let point = CGPoint(x: 5, y: 4)
58 |
59 | let lengthSquared = point.lengthSquared
60 |
61 | XCTAssertEqual(lengthSquared, 41)
62 | }
63 |
64 | func testLength() {
65 | let point = CGPoint(x: 3, y: 4)
66 |
67 | let length = point.length
68 |
69 | XCTAssertEqual(length, 5)
70 | }
71 |
72 | func testAngle() {
73 | let point = CGPoint(x: 1, y: 1)
74 | XCTAssertEqual(point.angle, .pi / 4.0)
75 | }
76 |
77 | func testAngleWhenZeroReturnZero() {
78 | XCTAssertEqual(CGPoint.zero.angle, .zero)
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/Tests/Extensions/CGRectExtensionsTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DirectedGraph
3 |
4 | class CGRectExtensionsTests: XCTestCase {
5 | func testCenter() {
6 | let rect = CGRect(origin: CGPoint(x: 10, y: 40), size: CGSize(width: 50, height: 60))
7 |
8 | let center = rect.center
9 |
10 | XCTAssertEqual(center, CGPoint(x: 35, y: 70))
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Tests/Extensions/CGSizeExtensionsTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DirectedGraph
3 |
4 | class CGSizeExtensionsTests: XCTestCase {
5 | func testAddition() {
6 | let size1 = CGSize(width: 40, height: 121)
7 | let size2 = CGSize(width: 50, height: 32)
8 |
9 | let added = size1 + size2
10 |
11 | XCTAssertEqual(added, CGSize(width: 90, height: 153))
12 | }
13 |
14 | func testDivision() {
15 | let size = CGSize(width: 40, height: 121)
16 |
17 | let divided = size / 10
18 |
19 | XCTAssertEqual(divided, CGSize(width: 4, height: 12.1))
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Tests/Extensions/CollectionExtensionsTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DirectedGraph
3 |
4 | class CollectionExtensionsTests: XCTestCase {
5 | func testAveragePoint() {
6 | let point1 = CGPoint(x: 40, y: 121)
7 | let point2 = CGPoint(x: 50, y: 32)
8 | let array = [point1, point2]
9 |
10 | let average = array.averagePoint()
11 |
12 | XCTAssertEqual(average, CGPoint(x: 45, y: 76.5))
13 | }
14 |
15 | func testWhenCollectionIsEmptyAveragePointReturnNil() {
16 | XCTAssertNil([CGPoint]().averagePoint())
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/Tests/Extensions/ComparableExtensionsTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DirectedGraph
3 |
4 | class ComparableExtensionsTests: XCTestCase {
5 | override func setUpWithError() throws {
6 | // Put setup code here. This method is called before the invocation of each test method in the class.
7 | }
8 |
9 | override func tearDownWithError() throws {
10 | // Put teardown code here. This method is called after the invocation of each test method in the class.
11 | }
12 |
13 | func testClampedWhenSmallerReturnMinValue() throws {
14 | let minValue = 3
15 | let maxValue = 10
16 | let range = minValue...maxValue
17 |
18 | let clamped = 2.clamped(to: range)
19 |
20 | XCTAssertEqual(clamped, minValue)
21 | }
22 |
23 | func testClampedWhenBiggerReturnMaxValue() throws {
24 | let minValue = 1.2
25 | let maxValue = 9.5
26 | let range = minValue...maxValue
27 |
28 | let clamped = 9.6.clamped(to: range)
29 |
30 | XCTAssertEqual(clamped, maxValue)
31 | }
32 |
33 | func testClampedWhenInRangeReturnValue() throws {
34 | let minValue = -1
35 | let maxValue = 6
36 | let value = 5
37 | let range = minValue...maxValue
38 |
39 | let clamped = value.clamped(to: range)
40 |
41 | XCTAssertEqual(clamped, value)
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/Tests/Utilities/PaletteTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DirectedGraph
3 |
4 | class PaletteTests: XCTestCase {
5 | override func setUpWithError() throws {
6 | // Put setup code here. This method is called before the invocation of each test method in the class.
7 | }
8 |
9 | override func tearDownWithError() throws {
10 | // Put teardown code here. This method is called after the invocation of each test method in the class.
11 | }
12 |
13 | func testColorForIndexIsCyclic() throws {
14 | let palette = Palette(colorCount: 3)
15 |
16 | let colorAt0 = palette.color(for: 0)
17 | let colorAt3 = palette.color(for: 3)
18 |
19 | XCTAssertEqual(colorAt0, colorAt3)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/Tests/Views/EdgeViewModelTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DirectedGraph
3 |
4 | class EdgeViewModelTests: XCTestCase {
5 | private let sourceNode = NodeViewModel(SimpleNode(id: "1", group: 3))
6 | private let targetNode = NodeViewModel(SimpleNode(id: "2", group: 3))
7 | private var cancellable: Any?
8 |
9 | func testEndIsNotNaNWhenSourceAndTargetAtSamePosition() {
10 | sourceNode.position = CGPoint(25, 40)
11 | targetNode.position = CGPoint(25, 40)
12 |
13 | let viewModel = EdgeViewModel(source: sourceNode, target: targetNode, value: .zero)
14 |
15 | XCTAssertFalse(viewModel.end.x.isNaN)
16 | }
17 |
18 | func testSourceChangeTriggerEdgeChange() {
19 | let viewModel = EdgeViewModel(source: sourceNode, target: targetNode, value: 40)
20 | var hasChanged = false
21 | cancellable = viewModel.objectWillChange.sink { _ in
22 | hasChanged = true
23 | }
24 |
25 | sourceNode.position.x += 2
26 |
27 | XCTAssertTrue(hasChanged)
28 | }
29 |
30 | func testTargetChangeTriggerEdgeChange() {
31 | let viewModel = EdgeViewModel(source: sourceNode, target: targetNode, value: 40)
32 | var hasChanged = false
33 | cancellable = viewModel.objectWillChange.sink { _ in
34 | hasChanged = true
35 | }
36 |
37 | targetNode.position.x += 2
38 |
39 | XCTAssertTrue(hasChanged)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/Tests/Views/EdgeViewTest.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import SwiftUI
3 | import ViewInspector
4 | @testable import DirectedGraph
5 |
6 | class EdgeViewTests: XCTestCase {
7 | private let nodeViewModel1 = NodeViewModel(SimpleNode(id: "1", group: 3))
8 | private let nodeViewModel2 = NodeViewModel(SimpleNode(id: "2", group: 3))
9 |
10 | func testStartAndEndPositions() throws {
11 | let position1 = CGPoint(x: 10, y: 20)
12 | let position2 = CGPoint(x: 60, y: -12)
13 | nodeViewModel1.position = position1
14 | nodeViewModel2.position = position2
15 | let viewModel = EdgeViewModel(source: nodeViewModel1, target: nodeViewModel2, value: 40)
16 | let view = makeView(viewModel)
17 |
18 | let arrow = try view.inspect().zStack().view(Arrow.self, 0).actualView()
19 |
20 | XCTAssertEqual(arrow.start, position1)
21 | XCTAssertEqual(arrow.end, viewModel.end)
22 | }
23 |
24 | func testWhenShowValueDisplayText() throws {
25 | let viewModel = EdgeViewModel(source: nodeViewModel1, target: nodeViewModel2, value: 40)
26 | viewModel.showValue = true
27 | let view = makeView(viewModel)
28 |
29 | let text = try view.inspect().zStack().text(1).string()
30 |
31 | XCTAssertEqual(text, "40.0")
32 | }
33 |
34 | func makeView(_ viewModel: EdgeViewModel) -> EdgeView {
35 | return EdgeView(viewModel: viewModel)
36 | }
37 | }
38 |
39 | extension EdgeView: Inspectable {
40 |
41 | }
42 |
43 | extension Arrow: Inspectable {
44 |
45 | }
46 |
--------------------------------------------------------------------------------
/Tests/Views/GraphViewModelTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DirectedGraph
3 |
4 | class GraphViewModelTests: XCTestCase {
5 | func testInit() {
6 | let node1 = SimpleNode(id: "1", group: 3)
7 | let node2 = SimpleNode(id: "2", group: 3)
8 | let edge = SimpleEdge(source: "1", target: "2", value: 3)
9 | let graph = SimpleGraph(nodes: [node1, node2], edges: [edge])
10 |
11 | let graphViewModel = GraphViewModel(graph)
12 |
13 | XCTAssertEqual(graphViewModel.graphNodes.count, 2)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Tests/Views/NodeViewModelTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import DirectedGraph
3 |
4 | class NodeViewModelTests: XCTestCase {
5 | func testInit() {
6 | let node = SimpleNode(id: "2", group: 3)
7 |
8 | let nodeViewModel = NodeViewModel(node)
9 |
10 | XCTAssertEqual(nodeViewModel.id, "2")
11 | XCTAssertEqual(nodeViewModel.position, .zero)
12 | XCTAssertEqual(nodeViewModel.velocity, .zero)
13 | XCTAssertEqual(nodeViewModel.size, .zero)
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Tests/Views/NodeViewTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | import SwiftUI
3 | import ViewInspector
4 | @testable import DirectedGraph
5 |
6 | class NodeViewTests: XCTestCase {
7 | private let viewModel = NodeViewModel(SimpleNode(id: "2", group: 3))
8 |
9 | func testInit() throws {
10 | let expectedText = "Test"
11 | let view = NodeView(viewModel: viewModel) {
12 | Text(expectedText)
13 | }
14 |
15 | let text = try view.inspect().view(SizeReader.self).zStack().text(0).string()
16 |
17 | XCTAssertEqual(text, expectedText)
18 | }
19 |
20 | func testPosition() throws {
21 | let expectedPosition = CGPoint(x: 1, y: 10)
22 | viewModel.position = expectedPosition
23 | let view = makeView(viewModel)
24 |
25 | let position = try view.inspect().view(SizeReader.self).position()
26 |
27 | XCTAssertEqual(position, expectedPosition)
28 | }
29 |
30 | func testDoubleTapToggleInteractive() throws {
31 | let view = makeView(viewModel)
32 |
33 | try view.callOnTapGesture()
34 |
35 | XCTAssertEqual(viewModel.interactive, true)
36 |
37 | try view.callOnTapGesture()
38 |
39 | XCTAssertEqual(viewModel.interactive, false)
40 | }
41 |
42 | func makeView(_ viewModel: NodeViewModel) -> NodeView {
43 | return NodeView(viewModel: viewModel) {
44 | Text("Test")
45 | }
46 | }
47 | }
48 |
49 | extension NodeView: Inspectable where Content == Text {
50 | func callOnTapGesture() throws {
51 | try inspect().view(SizeReader.self).callOnTapGesture()
52 | }
53 | }
54 |
55 | extension SizeReader: Inspectable where Content == Text {
56 |
57 | }
58 |
--------------------------------------------------------------------------------