From 9ca8cd8fdde2cc0e9439fa83bc2e0803fad7c53f Mon Sep 17 00:00:00 2001 From: li3zhen1 Date: Sun, 5 Nov 2023 10:50:14 -0500 Subject: [PATCH 1/7] Add skeleton --- Package.swift | 31 +++-- Sources/Grape/ForceDirectedGraph.swift | 80 +++++++++++++ Sources/Grape/ForceDirectedGraphContent.swift | 49 ++++++++ Sources/Grape/GraphContent.swift | 1 + Sources/Grape/GraphContentBuilder.swift | 110 ++++++++++++++++++ Sources/Grape/LinkMark.swift | 78 +++++++++++++ Sources/Grape/NodeMark.swift | 52 +++++++++ 7 files changed, 391 insertions(+), 10 deletions(-) create mode 100644 Sources/Grape/ForceDirectedGraph.swift create mode 100644 Sources/Grape/ForceDirectedGraphContent.swift create mode 100644 Sources/Grape/GraphContent.swift create mode 100644 Sources/Grape/GraphContentBuilder.swift create mode 100644 Sources/Grape/LinkMark.swift create mode 100644 Sources/Grape/NodeMark.swift diff --git a/Package.swift b/Package.swift index ba8781b..546fc34 100644 --- a/Package.swift +++ b/Package.swift @@ -24,6 +24,11 @@ let package = Package( targets: ["ForceSimulation"] ), + .library( + name: "Grape", + targets: ["Grape"] + ), + ], dependencies: [ @@ -33,6 +38,12 @@ let package = Package( targets: [ + .target( + name: "Grape", + dependencies: ["ForceSimulation"], + path: "Sources/Grape" + ), + // .target( // name: "NDTree", // path: "Sources/NDTree", @@ -43,7 +54,7 @@ let package = Package( // // "-whole-module-optimization", // // "-Ounchecked", // ]), - + // ] // ), @@ -62,14 +73,14 @@ let package = Package( name: "ForceSimulation", // dependencies: ["NDTree"], path: "Sources/ForceSimulation" - // , - // swiftSettings: [ - // .unsafeFlags([ - // "-cross-module-optimization", - // // "-whole-module-optimization", - // // "-Ounchecked", - // ]) - // ] + // , + // swiftSettings: [ + // .unsafeFlags([ + // "-cross-module-optimization", + // // "-whole-module-optimization", + // // "-Ounchecked", + // ]) + // ] ), .testTarget( @@ -83,6 +94,6 @@ let package = Package( // // "-Ounchecked", // ]) // ] - ), + ), ] ) diff --git a/Sources/Grape/ForceDirectedGraph.swift b/Sources/Grape/ForceDirectedGraph.swift new file mode 100644 index 0000000..9751b53 --- /dev/null +++ b/Sources/Grape/ForceDirectedGraph.swift @@ -0,0 +1,80 @@ +import ForceSimulation +import SwiftUI + +public protocol ForceDescriptor {} + +public struct CenterForce: ForceDescriptor { + public var x: Double + public var y: Double + public var strength: Double +} + +public struct ManyBodyForce: ForceDescriptor { + public var strength: Double + public var theta: Double + public var distanceMin: Double + public var distanceMax: Double +} + +public struct LinkForce: ForceDescriptor { + public var strength: Double + public var distance: Double + public var iterations: Int +} + +public struct CollideForce: ForceDescriptor { + public var strength: Double + public var radius: Double + public var iterations: Int +} + +public struct DirectionForce: ForceDescriptor { + public enum Dimension: Hashable { + case x + case y + } + public var strength: Double + public var targetOnDirection: Double + public var direction: DirectionForce.Dimension +} + +public struct ForceField { + public let forces: [ForceDescriptor] +} + +@resultBuilder +public struct ForceFieldBuilder { + public static func buildBlock(_ components: ForceDescriptor...) -> ForceField { + return ForceField(forces: components) + } +} + +public struct ForceDirectedGraph: View { + public struct Content { + var nodes: [NodeMark] + var links: [LinkMark] + } + + public struct ForceSpec { + + } + + public var body: some View { + EmptyView() + } + + private let content: Content + private let simulation: Simulation2D + private let forceFieldDescriptor: ForceField + + public init( + @GraphContentBuilder _ buildGraphContent: () -> PartialGraphMark, + @ForceFieldBuilder _ buildForceField: () -> ForceField + ) { + let graphMark = buildGraphContent() + self.content = Content(nodes: graphMark.nodes, links: graphMark.links) + self.simulation = .init(nodeIds: graphMark.nodes.map(\.id)) + self.forceFieldDescriptor = buildForceField() + } + +} diff --git a/Sources/Grape/ForceDirectedGraphContent.swift b/Sources/Grape/ForceDirectedGraphContent.swift new file mode 100644 index 0000000..073f12e --- /dev/null +++ b/Sources/Grape/ForceDirectedGraphContent.swift @@ -0,0 +1,49 @@ + + +public struct ForceDirectedGraphContent { + + var nodes: [NodeMark] + var links: [LinkMark] + + init(@GraphContentBuilder builder: () -> PartialGraphMark) { + let graphMark = builder() + self.nodes = graphMark.nodes + self.links = graphMark.links + } + + init(nodes: [NodeMark], links: [LinkMark]) { + self.nodes = nodes + self.links = links + } +} + +func test() { + let graph = ForceDirectedGraphContent { + + NodeMark(id: 2, fill: .accentColor, radius: 3.0, label: "Hello") + NodeMark(id: 3) + NodeMark(id: 4) + + 3 <-- 4 + 4 --> 2 + + for i in 20..<40 { + NodeMark(id: i) + i --> i + 1 + } + + FullyConnected { + NodeMark(id: 8) + NodeMark(id: 9) + } + + LinkMark(from: 3, to: 4) + LinkMark(from: 2, to: 4) + + (2 --> 3) { link in + link.strokeColor = .red + } + + } + +} diff --git a/Sources/Grape/GraphContent.swift b/Sources/Grape/GraphContent.swift new file mode 100644 index 0000000..e9ac79e --- /dev/null +++ b/Sources/Grape/GraphContent.swift @@ -0,0 +1 @@ +public protocol GraphContent { } \ No newline at end of file diff --git a/Sources/Grape/GraphContentBuilder.swift b/Sources/Grape/GraphContentBuilder.swift new file mode 100644 index 0000000..66801e7 --- /dev/null +++ b/Sources/Grape/GraphContentBuilder.swift @@ -0,0 +1,110 @@ +public final class PartialGraphMark: GraphContent { + var nodes: [NodeMark] + var links: [LinkMark] + + init(nodes: [NodeMark], links: [LinkMark]) { + self.nodes = nodes + self.links = links + } + + static var empty: PartialGraphMark { + return .init(nodes: [], links: []) + } + + @discardableResult + func with(node: NodeMark) -> Self { + nodes.append(node) + return self + } + + @discardableResult + func with(link: LinkMark) -> Self { + links.append(link) + return self + } + + @discardableResult + func with(partial: PartialGraphMark) -> Self { + links.append(contentsOf: partial.links) + nodes.append(contentsOf: partial.nodes) + return self + } +} + +public struct FullyConnected: GraphContent { + public var connectedPartial: PartialGraphMark + public init(@GraphContentBuilder builder: () -> PartialGraphMark) { + let result = builder() + result.links = [] + for i in result.nodes.indices { + for j in i + 1..(from: result.nodes[i].id, to: result.nodes[j].id)) + } + } + connectedPartial = result + } +} + +@resultBuilder +public struct GraphContentBuilder { + + public typealias Link = LinkMark + public typealias Node = NodeMark + public typealias PartialGraph = PartialGraphMark + + public static func buildPartialBlock(first content: Node) -> PartialGraph { + return PartialGraph(nodes: [content], links: []) + } + + public static func buildPartialBlock(first content: Link) -> PartialGraph { + return PartialGraph(nodes: [], links: [content]) + } + + public static func buildPartialBlock(accumulated: PartialGraph, next: Link) -> PartialGraph { + return accumulated.with(link: next) + } + + public static func buildPartialBlock(accumulated: PartialGraph, next: Node) -> PartialGraph { + return accumulated.with(node: next) + } + + public static func buildPartialBlock(accumulated: PartialGraph, next: PartialGraph) -> PartialGraph { + return accumulated.with(partial: next) + } + + public static func buildBlock() -> PartialGraph { + return PartialGraph.empty + } + + public static func buildExpression(_ expression: (NodeID, NodeID)) -> Link { + return Link(from: expression.0, to: expression.1) + } + + public static func buildExpression(_ expression: Node) -> Node { + return expression + } + + public static func buildExpression(_ expression: Link) -> Link { + return expression + } + + public static func buildExpression(_ expression: FullyConnected) -> PartialGraph { + return expression.connectedPartial + } + + public static func buildArray(_ components: [PartialGraph]) -> PartialGraph { + let partial = PartialGraph(nodes: [], links: []) + for expr in components { + partial.with(partial: expr) + } + return partial + } + + public static func buildExpression( + @GraphContentBuilder expression: () -> PartialGraphMark + ) -> PartialGraph { + return PartialGraph.empty + } + +} diff --git a/Sources/Grape/LinkMark.swift b/Sources/Grape/LinkMark.swift new file mode 100644 index 0000000..d0b39bd --- /dev/null +++ b/Sources/Grape/LinkMark.swift @@ -0,0 +1,78 @@ +import ForceSimulation +import SwiftUI + +@dynamicCallable +public struct LinkMark: GraphContent { + + public enum LabelDisplayStrategy { + case auto + case specified(Bool) + case byPageRank((Double, Double) -> Bool) + } + + public enum LabelPositioning { + case auto + } + + public enum ArrowStyle { + case none + case triangle + } + + public var id: EdgeID + + public var label: String? + public var labelColor: Color + public var labelDisplayStrategy: LabelDisplayStrategy + public var labelPositioning: LabelPositioning + + public var strokeColor: Color + public var strokeWidth: Double + public var strokeDashArray: [Double]? + + public var arrowStyle: ArrowStyle + + public init( + from: NodeID, + to: NodeID, + label: String? = nil, + labelColor: Color = .gray, + labelDisplayStrategy: LabelDisplayStrategy = .auto, + labelPositioning: LabelPositioning = .auto, + strokeColor: Color = .gray.opacity(0.2), + strokeWidth: Double = 1.0, + strokeDashArray: [Double]? = nil, + arrowStyle: ArrowStyle = .none + ) { + self.id = .init(from, to) + self.label = label + self.labelColor = labelColor + self.labelDisplayStrategy = labelDisplayStrategy + self.labelPositioning = labelPositioning + self.strokeColor = strokeColor + self.strokeWidth = strokeWidth + self.strokeDashArray = strokeDashArray + self.arrowStyle = arrowStyle + } + + public func dynamicallyCall(withArguments: [(inout Self) -> Void]) -> Self { + var _self = self + for argument in withArguments { + argument(&_self) + } + return _self + } +} + +infix operator --> : AssignmentPrecedence +infix operator <-- : AssignmentPrecedence + +extension Hashable { + public static func --> (lhs: Self, rhs: Self) -> LinkMark { + return LinkMark(from: lhs, to: rhs) + } + + public static func <-- (lhs: Self, rhs: Self) -> LinkMark { + return LinkMark(from: lhs, to: rhs) + } +} diff --git a/Sources/Grape/NodeMark.swift b/Sources/Grape/NodeMark.swift new file mode 100644 index 0000000..6dc1824 --- /dev/null +++ b/Sources/Grape/NodeMark.swift @@ -0,0 +1,52 @@ +import SwiftUI + + +public struct NodeMark: GraphContent { + + public enum LabelDisplayStrategy { + case auto + case specified(Bool) + case byPageRank((Double) -> Bool) + } + + public enum LabelPositioning { + case bottomOfMark + case topOfMark + case startAfterMark + case endBeforeMark + } + + public var id: NodeID + + public var fill: Color + public var strokeColor: Color? + public var strokeWidth: Double + public var radius: Double + public var label: String? + public var labelColor: Color + public var labelDisplayStrategy: LabelDisplayStrategy + public var labelPositioning: LabelPositioning + + public init( + id: NodeID, + fill: Color = .accentColor, + radius: Double = 3.0, + label: String? = nil, + labelColor: Color = .accentColor, + labelDisplayStrategy: LabelDisplayStrategy = .auto, + labelPositioning: LabelPositioning = .bottomOfMark, + strokeColor: Color? = nil, + strokeWidth: Double = 1 + ) { + self.id = id + self.fill = fill + self.radius = radius + self.label = label + self.labelColor = labelColor + self.labelDisplayStrategy = labelDisplayStrategy + self.labelPositioning = labelPositioning + + self.strokeColor = strokeColor + self.strokeWidth = strokeWidth + } +} From b916ec5e54ef20b7ae245e0b1e4e808747e6b1a9 Mon Sep 17 00:00:00 2001 From: li3zhen1 Date: Sun, 5 Nov 2023 11:37:10 -0500 Subject: [PATCH 2/7] Removing NodeID --- Sources/Grape/ForceDescriptor.swift | 62 ++++++++++++++++++ Sources/Grape/ForceDirectedGraph.swift | 84 ++++++++++--------------- Sources/Grape/GraphContentBuilder.swift | 9 ++- 3 files changed, 103 insertions(+), 52 deletions(-) create mode 100644 Sources/Grape/ForceDescriptor.swift diff --git a/Sources/Grape/ForceDescriptor.swift b/Sources/Grape/ForceDescriptor.swift new file mode 100644 index 0000000..4d1b432 --- /dev/null +++ b/Sources/Grape/ForceDescriptor.swift @@ -0,0 +1,62 @@ +import ForceSimulation +import simd + +public protocol ForceDescriptor { + func attachToSimulation(_ simulation: Simulation2D) +} + +public struct ForceField: ForceDescriptor { + public let forces: [ForceDescriptor] + + public func attachToSimulation(_ simulation: Simulation2D) where NodeID : Hashable { + for forceDescriptor in forces { + forceDescriptor.attachToSimulation(simulation) + } + } +} + +public struct CenterForce: ForceDescriptor { + public var x: Double + public var y: Double + public var strength: Double + + public func attachToSimulation(_ simulation: Simulation2D) where NodeID : Hashable { + simulation.createCenterForce(center: [x, y], strength: strength) + } +} + +public struct ManyBodyForce: ForceDescriptor { + + public var strength: Double + public var theta: Double + public var distanceMin: Double + public var distanceMax: Double + + public func attachToSimulation(_ simulation: Simulation2D) { + simulation.createManyBodyForce(strength: strength, nodeMass: .) + } +} + +public struct LinkForce: ForceDescriptor { + public var strength: Double + public var distance: Double + public var iterations: Int +} + +public struct CollideForce: ForceDescriptor { + public var strength: Double + public var radius: Double + public var iterations: Int +} + +public struct DirectionForce: ForceDescriptor { + public enum Dimension: Hashable { + case x + case y + } + public var strength: Double + public var targetOnDirection: Double + public var direction: DirectionForce.Dimension +} + + diff --git a/Sources/Grape/ForceDirectedGraph.swift b/Sources/Grape/ForceDirectedGraph.swift index 9751b53..f883c3b 100644 --- a/Sources/Grape/ForceDirectedGraph.swift +++ b/Sources/Grape/ForceDirectedGraph.swift @@ -1,47 +1,6 @@ import ForceSimulation import SwiftUI -public protocol ForceDescriptor {} - -public struct CenterForce: ForceDescriptor { - public var x: Double - public var y: Double - public var strength: Double -} - -public struct ManyBodyForce: ForceDescriptor { - public var strength: Double - public var theta: Double - public var distanceMin: Double - public var distanceMax: Double -} - -public struct LinkForce: ForceDescriptor { - public var strength: Double - public var distance: Double - public var iterations: Int -} - -public struct CollideForce: ForceDescriptor { - public var strength: Double - public var radius: Double - public var iterations: Int -} - -public struct DirectionForce: ForceDescriptor { - public enum Dimension: Hashable { - case x - case y - } - public var strength: Double - public var targetOnDirection: Double - public var direction: DirectionForce.Dimension -} - -public struct ForceField { - public let forces: [ForceDescriptor] -} - @resultBuilder public struct ForceFieldBuilder { public static func buildBlock(_ components: ForceDescriptor...) -> ForceField { @@ -51,25 +10,28 @@ public struct ForceFieldBuilder { public struct ForceDirectedGraph: View { public struct Content { - var nodes: [NodeMark] - var links: [LinkMark] - } - - public struct ForceSpec { + public var nodes: [NodeMark] + public var links: [LinkMark] + public init(nodes: [NodeMark], links: [LinkMark]) { + self.nodes = nodes + self.links = links + } } + public var body: some View { EmptyView() } - private let content: Content - private let simulation: Simulation2D - private let forceFieldDescriptor: ForceField + @usableFromInline let content: Content + @usableFromInline let simulation: Simulation2D + @usableFromInline let forceFieldDescriptor: ForceField + @inlinable public init( @GraphContentBuilder _ buildGraphContent: () -> PartialGraphMark, - @ForceFieldBuilder _ buildForceField: () -> ForceField + @ForceFieldBuilder forceField buildForceField: () -> ForceField ) { let graphMark = buildGraphContent() self.content = Content(nodes: graphMark.nodes, links: graphMark.links) @@ -77,4 +39,26 @@ public struct ForceDirectedGraph: View { self.forceFieldDescriptor = buildForceField() } + + @inlinable + func buildForceField() { + for forceDescriptor in forceFieldDescriptor.forces { + switch forceDescriptor { + case let centerForce as CenterForce: + simulation.addCenterForce(x: centerForce.x, y: centerForce.y, strength: centerForce.strength) + case let manyBodyForce as ManyBodyForce: + simulation.addManyBodyForce(strength: manyBodyForce.strength, theta: manyBodyForce.theta, distanceMin: manyBodyForce.distanceMin, distanceMax: manyBodyForce.distanceMax) + case let linkForce as LinkForce: + simulation.addLinkForce(strength: linkForce.strength, distance: linkForce.distance, iterations: linkForce.iterations) + case let collideForce as CollideForce: + simulation.addCollideForce(strength: collideForce.strength, radius: collideForce.radius, iterations: collideForce.iterations) + case let directionForce as DirectionForce: + simulation.addDirectionForce(strength: directionForce.strength, targetOnDirection: directionForce.targetOnDirection, direction: directionForce.direction) + default: + break + } + } + } + + } diff --git a/Sources/Grape/GraphContentBuilder.swift b/Sources/Grape/GraphContentBuilder.swift index 66801e7..99bbfc1 100644 --- a/Sources/Grape/GraphContentBuilder.swift +++ b/Sources/Grape/GraphContentBuilder.swift @@ -1,29 +1,34 @@ public final class PartialGraphMark: GraphContent { - var nodes: [NodeMark] - var links: [LinkMark] + @usableFromInline var nodes: [NodeMark] + @usableFromInline var links: [LinkMark] + @inlinable init(nodes: [NodeMark], links: [LinkMark]) { self.nodes = nodes self.links = links } + @inlinable static var empty: PartialGraphMark { return .init(nodes: [], links: []) } @discardableResult + @inlinable func with(node: NodeMark) -> Self { nodes.append(node) return self } @discardableResult + @inlinable func with(link: LinkMark) -> Self { links.append(link) return self } @discardableResult + @initializes func with(partial: PartialGraphMark) -> Self { links.append(contentsOf: partial.links) nodes.append(contentsOf: partial.nodes) From efdb0856d0eab8facb56e4e863a3851c8192245b Mon Sep 17 00:00:00 2001 From: li3zhen1 Date: Sun, 5 Nov 2023 14:38:44 -0500 Subject: [PATCH 3/7] Add minimal SwiftUI implementation --- .../project.pbxproj | 18 +++ .../ForceDirectedGraphExampleApp.swift | 4 +- .../ForceDirectedGraphSwiftUIExample.swift | 36 ++++++ Package.swift | 8 +- Sources/Grape/Contents/ForceDescriptor.swift | 101 ++++++++++++++++ .../Contents/ForceDirectedGraphContent.swift | 48 ++++++++ .../Grape/{ => Contents}/GraphContent.swift | 0 .../{ => Contents}/GraphContentBuilder.swift | 0 Sources/Grape/{ => Contents}/LinkMark.swift | 0 Sources/Grape/{ => Contents}/NodeMark.swift | 0 Sources/Grape/ForceDescriptor.swift | 62 ---------- Sources/Grape/ForceDirectedGraph.swift | 64 ---------- Sources/Grape/ForceDirectedGraphContent.swift | 49 -------- .../ForceDirectedGraph2DController.swift | 22 ++++ .../ForceDirectedGraph2DLayoutEngine.swift | 49 ++++++++ Sources/Grape/Views/ForceDirectedGraph.swift | 110 ++++++++++++++++++ 16 files changed, 391 insertions(+), 180 deletions(-) create mode 100644 Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphSwiftUIExample.swift create mode 100644 Sources/Grape/Contents/ForceDescriptor.swift create mode 100644 Sources/Grape/Contents/ForceDirectedGraphContent.swift rename Sources/Grape/{ => Contents}/GraphContent.swift (100%) rename Sources/Grape/{ => Contents}/GraphContentBuilder.swift (100%) rename Sources/Grape/{ => Contents}/LinkMark.swift (100%) rename Sources/Grape/{ => Contents}/NodeMark.swift (100%) delete mode 100644 Sources/Grape/ForceDescriptor.swift delete mode 100644 Sources/Grape/ForceDirectedGraph.swift delete mode 100644 Sources/Grape/ForceDirectedGraphContent.swift create mode 100644 Sources/Grape/Models/ForceDirectedGraph2DController.swift create mode 100644 Sources/Grape/Models/ForceDirectedGraph2DLayoutEngine.swift create mode 100644 Sources/Grape/Views/ForceDirectedGraph.swift diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj index ef8f6c7..8c36c4d 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj @@ -7,7 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + B70B52AD2AF822FF00A1E6CD /* ForceSimulation in Frameworks */ = {isa = PBXBuildFile; productRef = B70B52AC2AF822FF00A1E6CD /* ForceSimulation */; }; + B70B52AF2AF822FF00A1E6CD /* Grape in Frameworks */ = {isa = PBXBuildFile; productRef = B70B52AE2AF822FF00A1E6CD /* Grape */; }; B719E4112AE5FBFC009D6C24 /* ForceDirectedLatticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B719E4102AE5FBFC009D6C24 /* ForceDirectedLatticeView.swift */; }; + B7A830CD2AF822BB00A7AF6B /* ForceDirectedGraphSwiftUIExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7A830CC2AF822BB00A7AF6B /* ForceDirectedGraphSwiftUIExample.swift */; }; B7AFA55B2ADF4997009C7154 /* ForceDirectedGraphExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AFA55A2ADF4997009C7154 /* ForceDirectedGraphExampleApp.swift */; }; B7AFA55D2ADF4997009C7154 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AFA55C2ADF4997009C7154 /* ContentView.swift */; }; B7AFA55F2ADF4999009C7154 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B7AFA55E2ADF4999009C7154 /* Assets.xcassets */; }; @@ -18,6 +21,7 @@ /* Begin PBXFileReference section */ B719E4102AE5FBFC009D6C24 /* ForceDirectedLatticeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForceDirectedLatticeView.swift; sourceTree = ""; }; + B7A830CC2AF822BB00A7AF6B /* ForceDirectedGraphSwiftUIExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceDirectedGraphSwiftUIExample.swift; sourceTree = ""; }; B7AFA5572ADF4997009C7154 /* ForceDirectedGraphExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ForceDirectedGraphExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; B7AFA55A2ADF4997009C7154 /* ForceDirectedGraphExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceDirectedGraphExampleApp.swift; sourceTree = ""; }; B7AFA55C2ADF4997009C7154 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -32,7 +36,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B70B52AF2AF822FF00A1E6CD /* Grape in Frameworks */, B7AFA56B2ADF49AA009C7154 /* ForceSimulation in Frameworks */, + B70B52AD2AF822FF00A1E6CD /* ForceSimulation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -65,6 +71,7 @@ B7AFA5632ADF4999009C7154 /* ForceDirectedGraphExample.entitlements */, B7AFA5602ADF4999009C7154 /* Preview Content */, B7AFA56E2ADF49D6009C7154 /* Data.swift */, + B7A830CC2AF822BB00A7AF6B /* ForceDirectedGraphSwiftUIExample.swift */, ); path = ForceDirectedGraphExample; sourceTree = ""; @@ -95,6 +102,8 @@ name = ForceDirectedGraphExample; packageProductDependencies = ( B7AFA56A2ADF49AA009C7154 /* ForceSimulation */, + B70B52AC2AF822FF00A1E6CD /* ForceSimulation */, + B70B52AE2AF822FF00A1E6CD /* Grape */, ); productName = ForceDirectedGraphExample; productReference = B7AFA5572ADF4997009C7154 /* ForceDirectedGraphExample.app */; @@ -155,6 +164,7 @@ files = ( B7AFA55D2ADF4997009C7154 /* ContentView.swift in Sources */, B719E4112AE5FBFC009D6C24 /* ForceDirectedLatticeView.swift in Sources */, + B7A830CD2AF822BB00A7AF6B /* ForceDirectedGraphSwiftUIExample.swift in Sources */, B7AFA56F2ADF49D6009C7154 /* Data.swift in Sources */, B7AFA55B2ADF4997009C7154 /* ForceDirectedGraphExampleApp.swift in Sources */, ); @@ -362,6 +372,14 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + B70B52AC2AF822FF00A1E6CD /* ForceSimulation */ = { + isa = XCSwiftPackageProductDependency; + productName = ForceSimulation; + }; + B70B52AE2AF822FF00A1E6CD /* Grape */ = { + isa = XCSwiftPackageProductDependency; + productName = Grape; + }; B7AFA56A2ADF49AA009C7154 /* ForceSimulation */ = { isa = XCSwiftPackageProductDependency; productName = ForceSimulation; diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphExampleApp.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphExampleApp.swift index 83729b0..f8d5528 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphExampleApp.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphExampleApp.swift @@ -12,7 +12,9 @@ struct ForceDirectedGraphExampleApp: App { var body: some Scene { WindowGroup { // ContentView().padding(0) - ForceDirectedLatticeView().padding(0)//.ignoresSafeArea() +// ForceDirectedLatticeView().padding(0)//.ignoresSafeArea() + ForceDirectedGraphSwiftUIExample() + } } } diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphSwiftUIExample.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphSwiftUIExample.swift new file mode 100644 index 0000000..467e04f --- /dev/null +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphSwiftUIExample.swift @@ -0,0 +1,36 @@ +// +// ForceDirectedGraphSwiftUIExample.swift +// ForceDirectedGraphExample +// +// Created by li3zhen1 on 11/5/23. +// + +import Foundation +import SwiftUI +import Grape + + +struct ForceDirectedGraphSwiftUIExample: View { + let graphController = ForceDirectedGraph2DController() + var body: some View { + ForceDirectedGraph(controller: graphController) { + NodeMark(id: 0) + for i in 1..<10 { + NodeMark(id: i) + } + + for i in 0..<9 { + LinkMark(from: i, to: i+1) + } + + } forceField: { + LinkForce() + CenterForce() + ManyBodyForce() + } + .onAppear { + graphController.start() + } + + } +} diff --git a/Package.swift b/Package.swift index 546fc34..bf10135 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.6 +// swift-tools-version: 5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,9 +6,9 @@ import PackageDescription let package = Package( name: "Grape", platforms: [ - .macOS(.v11), - .iOS(.v14), - .watchOS(.v7), + .macOS(.v14), + .iOS(.v17), + .watchOS(.v9), ], products: [ diff --git a/Sources/Grape/Contents/ForceDescriptor.swift b/Sources/Grape/Contents/ForceDescriptor.swift new file mode 100644 index 0000000..582b712 --- /dev/null +++ b/Sources/Grape/Contents/ForceDescriptor.swift @@ -0,0 +1,101 @@ +import ForceSimulation +import simd + +public protocol ForceDescriptor { + func attachToSimulation(_ simulation: Simulation2D) +} + +public struct ForceSet { + public let forces: [ForceDescriptor] +} + +public struct CenterForce: ForceDescriptor { + public var x: Double + public var y: Double + public var strength: Double + + public init( + x: Double = 0.0, + y: Double = 0.0, + strength: Double = 0.5 + ) { + self.x = x + self.y = y + self.strength = strength + } + + public func attachToSimulation(_ simulation: Simulation2D) { + simulation.createCenterForce(center: [x, y], strength: strength) + } +} + +public struct ManyBodyForce: ForceDescriptor { + + public var strength: Double + public var mass: Simulation2D.ManyBodyForce.NodeMass + + public init( + strength: Double = -30.0, + mass: Simulation2D.ManyBodyForce.NodeMass = .constant(1.0) + ) { + self.strength = strength + self.mass = mass + } + + public func attachToSimulation(_ simulation: Simulation2D) { + simulation.createManyBodyForce(strength: strength, nodeMass: mass) + } +} + +public struct LinkForce: ForceDescriptor { + public var stiffness: Simulation2D.LinkForce.LinkStiffness + public var originalLength: Simulation2D.LinkForce.LinkLength + public var iterationsPerTick: UInt + @usableFromInline var links: [EdgeID] + + public init( + originalLength: Simulation2D.LinkForce.LinkLength = .constant(30.0), + stiffness: Simulation2D.LinkForce.LinkStiffness = .weightedByDegree { _, _ in 1.0 }, + iterationsPerTick: UInt = 1 + ) { + self.stiffness = stiffness + self.originalLength = originalLength + self.iterationsPerTick = iterationsPerTick + self.links = [] + } + + public func attachToSimulation(_ simulation: Simulation2D) { + simulation.createLinkForce(links, stiffness: stiffness, originalLength: originalLength, iterationsPerTick: iterationsPerTick) + } +} + +public struct CollideForce: ForceDescriptor { + public var strength: Double + public var radius: Simulation2D.CollideForce.CollideRadius = .constant(3.0) + public var iterationsPerTick: UInt = 1 + + public func attachToSimulation(_ simulation: Simulation2D) { + simulation.createCollideForce(radius: radius, strength: strength, iterationsPerTick: iterationsPerTick) + } +} + +public struct DirectionForce: ForceDescriptor { + + public var strength: Simulation2D.DirectionForce2D.Strength + public var targetOnDirection: Simulation2D.DirectionForce2D.TargetOnDirection + public var direction: Simulation2D.DirectionForce2D.Direction + + public init( + direction: Simulation2D.DirectionForce2D.Direction, + targetOnDirection: Simulation2D.DirectionForce2D.TargetOnDirection, + strength: Simulation2D.DirectionForce2D.Strength = .constant(1.0) + ) { + self.strength = strength + self.direction = direction + self.targetOnDirection = targetOnDirection + } + + public func attachToSimulation(_ simulation: Simulation2D) { + simulation.createPositionForce(direction: direction, targetOnDirection: targetOnDirection, strength: strength) + } +} diff --git a/Sources/Grape/Contents/ForceDirectedGraphContent.swift b/Sources/Grape/Contents/ForceDirectedGraphContent.swift new file mode 100644 index 0000000..f2f9464 --- /dev/null +++ b/Sources/Grape/Contents/ForceDirectedGraphContent.swift @@ -0,0 +1,48 @@ +import SwiftUI + +public struct ForceDirectedGraphContent { + + var nodes: [NodeMark] + var links: [LinkMark] + + init(@GraphContentBuilder builder: () -> PartialGraphMark) { + let graphMark = builder() + self.nodes = graphMark.nodes + self.links = graphMark.links + } + + init(nodes: [NodeMark], links: [LinkMark]) { + self.nodes = nodes + self.links = links + } +} + +struct TestGraphView: View { + + var controller = ForceDirectedGraph2DController() + + var body: some View { + ForceDirectedGraph(controller: controller) { + NodeMark(id: 2, fill: .accentColor, radius: 3.0, label: "Hello") + NodeMark(id: 3) + NodeMark(id: 4) + 3 <-- 4 + 4 --> 2 + for i in 20..<40 { + NodeMark(id: i) + i --> i + 1 + } + FullyConnected { + NodeMark(id: 8) + NodeMark(id: 9) + } + LinkMark(from: 3, to: 4) + LinkMark(from: 2, to: 4) + } forceField: { + LinkForce() + } + } + + + +} diff --git a/Sources/Grape/GraphContent.swift b/Sources/Grape/Contents/GraphContent.swift similarity index 100% rename from Sources/Grape/GraphContent.swift rename to Sources/Grape/Contents/GraphContent.swift diff --git a/Sources/Grape/GraphContentBuilder.swift b/Sources/Grape/Contents/GraphContentBuilder.swift similarity index 100% rename from Sources/Grape/GraphContentBuilder.swift rename to Sources/Grape/Contents/GraphContentBuilder.swift diff --git a/Sources/Grape/LinkMark.swift b/Sources/Grape/Contents/LinkMark.swift similarity index 100% rename from Sources/Grape/LinkMark.swift rename to Sources/Grape/Contents/LinkMark.swift diff --git a/Sources/Grape/NodeMark.swift b/Sources/Grape/Contents/NodeMark.swift similarity index 100% rename from Sources/Grape/NodeMark.swift rename to Sources/Grape/Contents/NodeMark.swift diff --git a/Sources/Grape/ForceDescriptor.swift b/Sources/Grape/ForceDescriptor.swift deleted file mode 100644 index 4d1b432..0000000 --- a/Sources/Grape/ForceDescriptor.swift +++ /dev/null @@ -1,62 +0,0 @@ -import ForceSimulation -import simd - -public protocol ForceDescriptor { - func attachToSimulation(_ simulation: Simulation2D) -} - -public struct ForceField: ForceDescriptor { - public let forces: [ForceDescriptor] - - public func attachToSimulation(_ simulation: Simulation2D) where NodeID : Hashable { - for forceDescriptor in forces { - forceDescriptor.attachToSimulation(simulation) - } - } -} - -public struct CenterForce: ForceDescriptor { - public var x: Double - public var y: Double - public var strength: Double - - public func attachToSimulation(_ simulation: Simulation2D) where NodeID : Hashable { - simulation.createCenterForce(center: [x, y], strength: strength) - } -} - -public struct ManyBodyForce: ForceDescriptor { - - public var strength: Double - public var theta: Double - public var distanceMin: Double - public var distanceMax: Double - - public func attachToSimulation(_ simulation: Simulation2D) { - simulation.createManyBodyForce(strength: strength, nodeMass: .) - } -} - -public struct LinkForce: ForceDescriptor { - public var strength: Double - public var distance: Double - public var iterations: Int -} - -public struct CollideForce: ForceDescriptor { - public var strength: Double - public var radius: Double - public var iterations: Int -} - -public struct DirectionForce: ForceDescriptor { - public enum Dimension: Hashable { - case x - case y - } - public var strength: Double - public var targetOnDirection: Double - public var direction: DirectionForce.Dimension -} - - diff --git a/Sources/Grape/ForceDirectedGraph.swift b/Sources/Grape/ForceDirectedGraph.swift deleted file mode 100644 index f883c3b..0000000 --- a/Sources/Grape/ForceDirectedGraph.swift +++ /dev/null @@ -1,64 +0,0 @@ -import ForceSimulation -import SwiftUI - -@resultBuilder -public struct ForceFieldBuilder { - public static func buildBlock(_ components: ForceDescriptor...) -> ForceField { - return ForceField(forces: components) - } -} - -public struct ForceDirectedGraph: View { - public struct Content { - public var nodes: [NodeMark] - public var links: [LinkMark] - - public init(nodes: [NodeMark], links: [LinkMark]) { - self.nodes = nodes - self.links = links - } - } - - - public var body: some View { - EmptyView() - } - - @usableFromInline let content: Content - @usableFromInline let simulation: Simulation2D - @usableFromInline let forceFieldDescriptor: ForceField - - @inlinable - public init( - @GraphContentBuilder _ buildGraphContent: () -> PartialGraphMark, - @ForceFieldBuilder forceField buildForceField: () -> ForceField - ) { - let graphMark = buildGraphContent() - self.content = Content(nodes: graphMark.nodes, links: graphMark.links) - self.simulation = .init(nodeIds: graphMark.nodes.map(\.id)) - self.forceFieldDescriptor = buildForceField() - } - - - @inlinable - func buildForceField() { - for forceDescriptor in forceFieldDescriptor.forces { - switch forceDescriptor { - case let centerForce as CenterForce: - simulation.addCenterForce(x: centerForce.x, y: centerForce.y, strength: centerForce.strength) - case let manyBodyForce as ManyBodyForce: - simulation.addManyBodyForce(strength: manyBodyForce.strength, theta: manyBodyForce.theta, distanceMin: manyBodyForce.distanceMin, distanceMax: manyBodyForce.distanceMax) - case let linkForce as LinkForce: - simulation.addLinkForce(strength: linkForce.strength, distance: linkForce.distance, iterations: linkForce.iterations) - case let collideForce as CollideForce: - simulation.addCollideForce(strength: collideForce.strength, radius: collideForce.radius, iterations: collideForce.iterations) - case let directionForce as DirectionForce: - simulation.addDirectionForce(strength: directionForce.strength, targetOnDirection: directionForce.targetOnDirection, direction: directionForce.direction) - default: - break - } - } - } - - -} diff --git a/Sources/Grape/ForceDirectedGraphContent.swift b/Sources/Grape/ForceDirectedGraphContent.swift deleted file mode 100644 index 073f12e..0000000 --- a/Sources/Grape/ForceDirectedGraphContent.swift +++ /dev/null @@ -1,49 +0,0 @@ - - -public struct ForceDirectedGraphContent { - - var nodes: [NodeMark] - var links: [LinkMark] - - init(@GraphContentBuilder builder: () -> PartialGraphMark) { - let graphMark = builder() - self.nodes = graphMark.nodes - self.links = graphMark.links - } - - init(nodes: [NodeMark], links: [LinkMark]) { - self.nodes = nodes - self.links = links - } -} - -func test() { - let graph = ForceDirectedGraphContent { - - NodeMark(id: 2, fill: .accentColor, radius: 3.0, label: "Hello") - NodeMark(id: 3) - NodeMark(id: 4) - - 3 <-- 4 - 4 --> 2 - - for i in 20..<40 { - NodeMark(id: i) - i --> i + 1 - } - - FullyConnected { - NodeMark(id: 8) - NodeMark(id: 9) - } - - LinkMark(from: 3, to: 4) - LinkMark(from: 2, to: 4) - - (2 --> 3) { link in - link.strokeColor = .red - } - - } - -} diff --git a/Sources/Grape/Models/ForceDirectedGraph2DController.swift b/Sources/Grape/Models/ForceDirectedGraph2DController.swift new file mode 100644 index 0000000..23bc4d9 --- /dev/null +++ b/Sources/Grape/Models/ForceDirectedGraph2DController.swift @@ -0,0 +1,22 @@ +import Observation + + +@Observable +public class ForceDirectedGraph2DController { + + @ObservationIgnored + @usableFromInline + weak var layoutEngine: ForceDirectedGraph2DLayoutEngine? + + public init() { + + } + + public func start() { + layoutEngine?.start() + } + + public func stop() { + layoutEngine?.stop() + } +} \ No newline at end of file diff --git a/Sources/Grape/Models/ForceDirectedGraph2DLayoutEngine.swift b/Sources/Grape/Models/ForceDirectedGraph2DLayoutEngine.swift new file mode 100644 index 0000000..f72ccef --- /dev/null +++ b/Sources/Grape/Models/ForceDirectedGraph2DLayoutEngine.swift @@ -0,0 +1,49 @@ +import ForceSimulation +import Observation +import SwiftUI + +protocol LayoutEngine { + +} + +@Observable +public class ForceDirectedGraph2DLayoutEngine: LayoutEngine { + + public var simulation: Simulation2D + + @ObservationIgnored + let frameRate: Double = 60.0 + + @ObservationIgnored + var scheduledTimer: Timer? = nil + + @inlinable + public init(initialSimulation: Simulation2D) { + self.simulation = initialSimulation + } + + public func start() { + guard self.scheduledTimer == nil else { return } + self.scheduledTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / frameRate, repeats: true) { [weak self] _ in + self?.tick() + } + } + + public func stop() { + self.scheduledTimer?.invalidate() + self.scheduledTimer = nil + } + + public func tick() { + withMutation(keyPath: \.simulation) { + simulation.tick() + } + } + + public func tick(waitingForTickingOn queue: DispatchQueue) { + queue.asyncAndWait { + self.simulation.tick() + } + withMutation(keyPath: \.self.simulation) { } + } +} diff --git a/Sources/Grape/Views/ForceDirectedGraph.swift b/Sources/Grape/Views/ForceDirectedGraph.swift new file mode 100644 index 0000000..8695bbc --- /dev/null +++ b/Sources/Grape/Views/ForceDirectedGraph.swift @@ -0,0 +1,110 @@ +import ForceSimulation +import SwiftUI + +@resultBuilder +public struct ForceFieldBuilder { + public static func buildBlock(_ components: ForceDescriptor...) -> [ForceDescriptor] { + return components + } +} + +public struct ForceDirectedGraph: View { + public struct Content { + public var nodes: [NodeMark] + public var links: [LinkMark] + + public init(nodes: [NodeMark], links: [LinkMark]) { + self.nodes = nodes + self.links = links + } + } + + @usableFromInline + var nodeIdToIndexLookup: [NodeID: Int] + + public var body: some View { + Canvas { context, cgSize in + let centerX = cgSize.width / 2.0 + let centerY = cgSize.height / 2.0 + + for i in model.simulation.nodePositions.indices { + let node = content.nodes[i] + let x = centerX + model.simulation.nodePositions[i].x + let y = centerY + model.simulation.nodePositions[i].y + + let rect = CGRect(origin: .init(x: x, y: y), size: CGSize(width: 8.0, height: 8.0)) + + context.fill( + Path(ellipseIn: rect), with: .color(node.fill)) + context.stroke( + Path(ellipseIn: rect), with: .color(Color(nsColor: .windowBackgroundColor)), + style: StrokeStyle(lineWidth: 1.5)) + } + } + } + + @inlinable + func paintNodes(context: inout GraphicsContext, centerX: Double, centerY: Double) { + + for i in model.simulation.nodePositions.indices { + let node = content.nodes[i] + let x = centerX + model.simulation.nodePositions[i].x + let y = centerY + model.simulation.nodePositions[i].y + + let rect = CGRect(origin: .init(x: x, y: y), size: CGSize(width: 8.0, height: 8.0)) + + context.fill( + Path(ellipseIn: rect), with: .color(node.fill)) + context.stroke( + Path(ellipseIn: rect), with: .color(Color(nsColor: .windowBackgroundColor)), + style: StrokeStyle(lineWidth: 1.5)) + } + } + + @usableFromInline + @State var model: ForceDirectedGraph2DLayoutEngine + @usableFromInline var controller: ForceDirectedGraph2DController + + @usableFromInline let content: Content + @usableFromInline let forceFieldDescriptor: [ForceDescriptor] + + @inlinable + public init( + controller: ForceDirectedGraph2DController, + @GraphContentBuilder _ buildGraphContent: () -> PartialGraphMark, + @ForceFieldBuilder forceField buildForceField: () -> [ForceDescriptor] + ) { + let graphMark = buildGraphContent() + self.content = Content(nodes: graphMark.nodes, links: graphMark.links) + + let lookup = Dictionary( + uniqueKeysWithValues: graphMark.nodes.enumerated().map { ($1.id, $0) }) + + let simulation = Simulation2D(nodeIds: Array(graphMark.nodes.indices)) + self.forceFieldDescriptor = buildForceField() + + for forceDescriptor in forceFieldDescriptor { + if var linkForceDescriptor = forceDescriptor as? LinkForce { + // inject links + linkForceDescriptor.links = content.links.map { + .init( + lookup[$0.id.source]!, + lookup[$0.id.target]! + ) + } + linkForceDescriptor.attachToSimulation(simulation) + } else { + forceDescriptor.attachToSimulation(simulation) + } + } + + self.nodeIdToIndexLookup = consume lookup + let model = ForceDirectedGraph2DLayoutEngine( + initialSimulation: consume simulation + ) + controller.layoutEngine = model + self.model = model + self.controller = controller + } + +} From 45e96e65c560210a4259aa2e9e47cc75fe5a1371 Mon Sep 17 00:00:00 2001 From: li3zhen1 Date: Sun, 5 Nov 2023 14:52:05 -0500 Subject: [PATCH 4/7] Fix complier crashing problems --- .../ForceDirectedGraphSwiftUIExample.swift | 11 ++--- README.md | 43 +++++++++++++++++-- .../ForceDirectedGraph2DLayoutEngine.swift | 3 +- Sources/Grape/Views/ForceDirectedGraph.swift | 6 +-- 4 files changed, 50 insertions(+), 13 deletions(-) diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphSwiftUIExample.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphSwiftUIExample.swift index 467e04f..6e6dd4f 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphSwiftUIExample.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ForceDirectedGraphSwiftUIExample.swift @@ -13,13 +13,14 @@ import Grape struct ForceDirectedGraphSwiftUIExample: View { let graphController = ForceDirectedGraph2DController() var body: some View { + ForceDirectedGraph(controller: graphController) { - NodeMark(id: 0) - for i in 1..<10 { - NodeMark(id: i) - } - for i in 0..<9 { + NodeMark(id: 0, fill: .green) + NodeMark(id: 1, fill: .blue) + NodeMark(id: 2, fill: .yellow) + + for i in 0..<2 { LinkMark(from: i, to: i+1) } diff --git a/README.md b/README.md index 02e35e4..35f2788 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,48 @@ Source code: [ForceDirectedGraph3D/ContentView.swift](https://github.com/li3zhen ## Usage -Grape provides 2 kinds of classes, `NDTree` and `Simulation`. +### `Grape` + +`Grape` provides a SwiftUI view `ForceDirectedGraph`: + +```swift +struct ForceDirectedGraphSwiftUIExample: View { + let graphController = ForceDirectedGraph2DController() + var body: some View { + ForceDirectedGraph(controller: graphController) { + // Declare nodes and links like you would do in Swift Charts. + NodeMark(id: 0, fill: .green) + NodeMark(id: 1, fill: .blue) + NodeMark(id: 2, fill: .yellow) + for i in 0..<2 { + LinkMark(from: i, to: i+1) + } + + } forceField: { + // Declare forces like you would do in D3.js. + LinkForce() + CenterForce() + ManyBodyForce() + } + .onAppear { + // Let's start moving! + graphController.start() + } + + } +} +``` +> [!NOTE] +> `ForceDirectedGraph` is only a minimal working example. Please refere to the next section if you need a view that really works. + + +### `ForceSimulation` + +`ForceSimulation` module provides 2 kinds of classes, `NDTree` and `Simulation`. - `NDTree` is a KD-Tree data structure, which is used to accelerate the force simulation with [Barnes-Hut Approximation](https://jheer.github.io/barnes-hut/). - `Simulation` is a force simulation class, that enables you to create any dimensional simulation with velocity Verlet integration. -### Basic +#### Basic The basic concepts of simulations and forces can be found here: [Force simulations - D3](https://d3js.org/d3-force/simulation). You can simply create 2D or 3D simulations by using `Simulation2D` or `Simulation3D`: @@ -104,7 +141,7 @@ See [Example](https://github.com/li3zhen1/Grape/tree/main/Examples/ForceDirected
-### Advanced +#### Advanced Grape provides a set of generic based types that works with any SIMD-like data structures. To integrate Grape into platforms where `import simd` isn't supported, or higher dimensions, you need to create a struct conforming to the `VectorLike` protocol. For ease of use, it's also recommended to add some type aliases. Here’s how you can do it: diff --git a/Sources/Grape/Models/ForceDirectedGraph2DLayoutEngine.swift b/Sources/Grape/Models/ForceDirectedGraph2DLayoutEngine.swift index f72ccef..f0315ec 100644 --- a/Sources/Grape/Models/ForceDirectedGraph2DLayoutEngine.swift +++ b/Sources/Grape/Models/ForceDirectedGraph2DLayoutEngine.swift @@ -9,7 +9,7 @@ protocol LayoutEngine { @Observable public class ForceDirectedGraph2DLayoutEngine: LayoutEngine { - public var simulation: Simulation2D + var simulation: Simulation2D @ObservationIgnored let frameRate: Double = 60.0 @@ -17,7 +17,6 @@ public class ForceDirectedGraph2DLayoutEngine: LayoutEngine { @ObservationIgnored var scheduledTimer: Timer? = nil - @inlinable public init(initialSimulation: Simulation2D) { self.simulation = initialSimulation } diff --git a/Sources/Grape/Views/ForceDirectedGraph.swift b/Sources/Grape/Views/ForceDirectedGraph.swift index 8695bbc..dfd4370 100644 --- a/Sources/Grape/Views/ForceDirectedGraph.swift +++ b/Sources/Grape/Views/ForceDirectedGraph.swift @@ -43,7 +43,7 @@ public struct ForceDirectedGraph: View { } } - @inlinable + // @inlinable func paintNodes(context: inout GraphicsContext, centerX: Double, centerY: Double) { for i in model.simulation.nodePositions.indices { @@ -98,9 +98,9 @@ public struct ForceDirectedGraph: View { } } - self.nodeIdToIndexLookup = consume lookup + self.nodeIdToIndexLookup = lookup let model = ForceDirectedGraph2DLayoutEngine( - initialSimulation: consume simulation + initialSimulation: simulation ) controller.layoutEngine = model self.model = model From 1bafb9994a2029c4526323cd793c6110bd1a4433 Mon Sep 17 00:00:00 2001 From: li3zhen1 Date: Sun, 5 Nov 2023 14:53:33 -0500 Subject: [PATCH 5/7] Add SwiftUI Example --- README.md | 2 - .../Simulation2D.CenterForce.swift | 89 +++++++++---------- 2 files changed, 44 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 35f2788..6f620b3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ swift workflow swift package index swift package index -

A Swift library for force simulation and graph visualization. @@ -83,7 +82,6 @@ struct ForceDirectedGraphSwiftUIExample: View { for i in 0..<2 { LinkMark(from: i, to: i+1) } - } forceField: { // Declare forces like you would do in D3.js. LinkForce() diff --git a/Sources/ForceSimulation/Simulation2D/Simulation2D.CenterForce.swift b/Sources/ForceSimulation/Simulation2D/Simulation2D.CenterForce.swift index ef6af29..8e99d09 100644 --- a/Sources/ForceSimulation/Simulation2D/Simulation2D.CenterForce.swift +++ b/Sources/ForceSimulation/Simulation2D/Simulation2D.CenterForce.swift @@ -5,63 +5,62 @@ // Created by li3zhen1 on 10/16/23. // - #if canImport(simd) -import simd + import simd -extension Simulation2D { - /// A force that drives nodes towards the center. - /// - /// Center force is relatively fast, the complexity is `O(n)`, - /// where `n` is the number of nodes. - /// See [Collide Force - D3](https://d3js.org/d3-force/collide). - final public class CenterForce: ForceLike - where NodeID: Hashable { - public typealias V = simd_double2 + extension Simulation2D { + /// A force that drives nodes towards the center. + /// + /// Center force is relatively fast, the complexity is `O(n)`, + /// where `n` is the number of nodes. + /// See [Collide Force - D3](https://d3js.org/d3-force/collide). + final public class CenterForce: ForceLike + where NodeID: Hashable { + public typealias V = simd_double2 - public var center: V - public var strength: V.Scalar - @usableFromInline weak var simulation: Simulation2D? + public var center: V + public var strength: V.Scalar + @usableFromInline weak var simulation: Simulation2D? - @inlinable internal init(center: V, strength: V.Scalar) { - self.center = center - self.strength = strength - } + @inlinable internal init(center: V, strength: V.Scalar) { + self.center = center + self.strength = strength + } - public func apply() { - guard let sim = self.simulation else { return } - // let alpha = sim.alpha + public func apply() { + guard let sim = self.simulation else { return } + // let alpha = sim.alpha - var meanPosition = V.zero - for n in sim.nodePositions { - meanPosition += n //.position - } - let delta = meanPosition * (self.strength / V.Scalar(sim.nodePositions.count)) + var meanPosition = V.zero + for n in sim.nodePositions { + meanPosition += n //.position + } + let delta = meanPosition * (self.strength / V.Scalar(sim.nodePositions.count)) - for i in sim.nodePositions.indices { - sim.nodePositions[i] -= delta + for i in sim.nodePositions.indices { + sim.nodePositions[i] -= delta + } } + } - } + /// Create a center force that drives nodes towards the center. + /// + /// Center force is relatively fast, the complexity is `O(n)`, + /// where `n` is the number of nodes. + /// See [Collide Force - D3](https://d3js.org/d3-force/collide). + /// - Parameters: + /// - center: The center of the force. + /// - strength: The strength of the force. + @discardableResult + public func createCenterForce(center: V, strength: V.Scalar = 0.1) -> CenterForce { + let f = CenterForce(center: center, strength: strength) + f.simulation = self + self.forces.append(f) + return f + } - /// Create a center force that drives nodes towards the center. - /// - /// Center force is relatively fast, the complexity is `O(n)`, - /// where `n` is the number of nodes. - /// See [Collide Force - D3](https://d3js.org/d3-force/collide). - /// - Parameters: - /// - center: The center of the force. - /// - strength: The strength of the force. - @discardableResult - public func createCenterForce(center: V, strength: V.Scalar = 0.1) -> CenterForce { - let f = CenterForce(center: center, strength: strength) - f.simulation = self - self.forces.append(f) - return f } -} - #endif From 772917a5b848a3618118d3ed49f3d0acf179f25e Mon Sep 17 00:00:00 2001 From: li3zhen1 Date: Sun, 5 Nov 2023 14:55:06 -0500 Subject: [PATCH 6/7] Update Readme --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6f620b3..1977f20 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,13 @@ Source code: [ForceDirectedGraph3D/ContentView.swift](https://github.com/li3zhen ## Usage -### `Grape` +### `Grape` (WIP) `Grape` provides a SwiftUI view `ForceDirectedGraph`: +> [!IMPORTANT] +> `ForceDirectedGraph` is only a minimal working example. Please refer to the next section to create a more complex view. + ```swift struct ForceDirectedGraphSwiftUIExample: View { let graphController = ForceDirectedGraph2DController() @@ -96,8 +99,7 @@ struct ForceDirectedGraphSwiftUIExample: View { } } ``` -> [!NOTE] -> `ForceDirectedGraph` is only a minimal working example. Please refere to the next section if you need a view that really works. + ### `ForceSimulation` From 5fa66400e90377b11269f92a2cb45b8c8cc80393 Mon Sep 17 00:00:00 2001 From: li3zhen1 Date: Sun, 5 Nov 2023 15:02:21 -0500 Subject: [PATCH 7/7] Add Link Rendering --- README.md | 6 +-- Sources/Grape/Views/ForceDirectedGraph.swift | 40 ++++++++++++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 1977f20..55533c9 100644 --- a/README.md +++ b/README.md @@ -66,15 +66,13 @@ Source code: [ForceDirectedGraph3D/ContentView.swift](https://github.com/li3zhen ## Usage -### `Grape` (WIP) - -`Grape` provides a SwiftUI view `ForceDirectedGraph`: +### `Grape` > [!IMPORTANT] > `ForceDirectedGraph` is only a minimal working example. Please refer to the next section to create a more complex view. ```swift -struct ForceDirectedGraphSwiftUIExample: View { +struct MyGraph: View { let graphController = ForceDirectedGraph2DController() var body: some View { ForceDirectedGraph(controller: graphController) { diff --git a/Sources/Grape/Views/ForceDirectedGraph.swift b/Sources/Grape/Views/ForceDirectedGraph.swift index dfd4370..a4d87d2 100644 --- a/Sources/Grape/Views/ForceDirectedGraph.swift +++ b/Sources/Grape/Views/ForceDirectedGraph.swift @@ -27,18 +27,44 @@ public struct ForceDirectedGraph: View { let centerX = cgSize.width / 2.0 let centerY = cgSize.height / 2.0 + for i in self.content.links { + let source = self.nodeIdToIndexLookup[i.id.source]! + let target = self.nodeIdToIndexLookup[i.id.target]! + + let sourceX = centerX + model.simulation.nodePositions[source].x + let sourceY = centerY + model.simulation.nodePositions[source].y + let targetX = centerX + model.simulation.nodePositions[target].x + let targetY = centerY + model.simulation.nodePositions[target].y + + context.stroke( + Path { path in + path.move(to: CGPoint(x: sourceX, y: sourceY)) + path.addLine(to: CGPoint(x: targetX, y: targetY)) + }, + with: .color(i.strokeColor), + style: StrokeStyle(lineWidth: i.strokeWidth) + ) + } + for i in model.simulation.nodePositions.indices { let node = content.nodes[i] - let x = centerX + model.simulation.nodePositions[i].x - let y = centerY + model.simulation.nodePositions[i].y + let x = centerX + model.simulation.nodePositions[i].x - node.radius + let y = centerY + model.simulation.nodePositions[i].y - node.radius - let rect = CGRect(origin: .init(x: x, y: y), size: CGSize(width: 8.0, height: 8.0)) + let rect = CGRect( + origin: .init(x: x, y: y), + size: CGSize( + width: node.radius * 2, height: node.radius * 2 + ) + ) context.fill( Path(ellipseIn: rect), with: .color(node.fill)) - context.stroke( - Path(ellipseIn: rect), with: .color(Color(nsColor: .windowBackgroundColor)), - style: StrokeStyle(lineWidth: 1.5)) + if let strokeColor = node.strokeColor { + context.stroke( + Path(ellipseIn: rect), with: .color(Color(strokeColor)), + style: StrokeStyle(lineWidth: node.strokeWidth)) + } } } } @@ -97,7 +123,7 @@ public struct ForceDirectedGraph: View { forceDescriptor.attachToSimulation(simulation) } } - + self.nodeIdToIndexLookup = lookup let model = ForceDirectedGraph2DLayoutEngine( initialSimulation: simulation