diff --git a/Examples/ForceDirectedGraph3D/ForceDirectedGraph3D/ContentView.swift b/Examples/ForceDirectedGraph3D/ForceDirectedGraph3D/ContentView.swift index 598fb90..3a375d9 100644 --- a/Examples/ForceDirectedGraph3D/ForceDirectedGraph3D/ContentView.swift +++ b/Examples/ForceDirectedGraph3D/ForceDirectedGraph3D/ContentView.swift @@ -44,7 +44,11 @@ func buildSimulation() -> Simulation3D { forceField: My3DForce() ) +<<<<<<< HEAD + for _ in 0..<720 { +======= for i in 0..<720 { +>>>>>>> main sim.tick() } return sim diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj index dab62f5..4217179 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj @@ -11,7 +11,11 @@ B70B52AF2AF822FF00A1E6CD /* Grape in Frameworks */ = {isa = PBXBuildFile; productRef = B70B52AE2AF822FF00A1E6CD /* Grape */; }; B71759592AFBFC4B000DF006 /* Miserables.swift in Sources */ = {isa = PBXBuildFile; fileRef = B71759582AFBFC4B000DF006 /* Miserables.swift */; }; B717595B2AFBFDBD000DF006 /* Lattice.swift in Sources */ = {isa = PBXBuildFile; fileRef = B717595A2AFBFDBD000DF006 /* Lattice.swift */; }; +<<<<<<< HEAD + B762092F2B49FCD000476B93 /* MermaidVisualization.swift in Sources */ = {isa = PBXBuildFile; fileRef = B762092E2B49FCD000476B93 /* MermaidVisualization.swift */; }; +======= B719E4112AE5FBFC009D6C24 /* ForceDirectedLatticeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B719E4102AE5FBFC009D6C24 /* ForceDirectedLatticeView.swift */; }; +>>>>>>> main B780DD7A2AF84ECB001C605F /* MyRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B780DD792AF84ECB001C605F /* MyRing.swift */; }; B7AFA55B2ADF4997009C7154 /* ForceDirectedGraphExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AFA55A2ADF4997009C7154 /* ForceDirectedGraphExampleApp.swift */; }; B7AFA55D2ADF4997009C7154 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7AFA55C2ADF4997009C7154 /* ContentView.swift */; }; @@ -24,7 +28,11 @@ /* Begin PBXFileReference section */ B71759582AFBFC4B000DF006 /* Miserables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Miserables.swift; sourceTree = ""; }; B717595A2AFBFDBD000DF006 /* Lattice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lattice.swift; sourceTree = ""; }; +<<<<<<< HEAD + B762092E2B49FCD000476B93 /* MermaidVisualization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MermaidVisualization.swift; sourceTree = ""; }; +======= B719E4102AE5FBFC009D6C24 /* ForceDirectedLatticeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ForceDirectedLatticeView.swift; sourceTree = ""; }; +>>>>>>> main B780DD792AF84ECB001C605F /* MyRing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MyRing.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 = ""; }; @@ -69,7 +77,10 @@ isa = PBXGroup; children = ( B780DD792AF84ECB001C605F /* MyRing.swift */, +<<<<<<< HEAD +======= B719E4102AE5FBFC009D6C24 /* ForceDirectedLatticeView.swift */, +>>>>>>> main B7AFA55A2ADF4997009C7154 /* ForceDirectedGraphExampleApp.swift */, B7AFA55C2ADF4997009C7154 /* ContentView.swift */, B7AFA55E2ADF4999009C7154 /* Assets.xcassets */, @@ -78,6 +89,10 @@ B7AFA56E2ADF49D6009C7154 /* Data.swift */, B71759582AFBFC4B000DF006 /* Miserables.swift */, B717595A2AFBFDBD000DF006 /* Lattice.swift */, +<<<<<<< HEAD + B762092E2B49FCD000476B93 /* MermaidVisualization.swift */, +======= +>>>>>>> main ); path = ForceDirectedGraphExample; sourceTree = ""; @@ -173,6 +188,10 @@ B7AFA55D2ADF4997009C7154 /* ContentView.swift in Sources */, B719E4112AE5FBFC009D6C24 /* ForceDirectedLatticeView.swift in Sources */, B7AFA56F2ADF49D6009C7154 /* Data.swift in Sources */, +<<<<<<< HEAD + B762092F2B49FCD000476B93 /* MermaidVisualization.swift in Sources */, +======= +>>>>>>> main B71759592AFBFC4B000DF006 /* Miserables.swift in Sources */, B7AFA55B2ADF4997009C7154 /* ForceDirectedGraphExampleApp.swift in Sources */, ); diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ContentView.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ContentView.swift index fa418b2..9f779b1 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ContentView.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ContentView.swift @@ -71,8 +71,14 @@ enum ExampleKind { case ring case classicMiserable case lattice +<<<<<<< HEAD + case mermaid + + static let list: [ExampleKind] = [.ring, .classicMiserable, .lattice, .mermaid] +======= static let list: [ExampleKind] = [.ring, .classicMiserable, .lattice] +>>>>>>> main } extension ExampleKind { @@ -84,6 +90,11 @@ extension ExampleKind { return "Miserables" case .lattice: return "Lattice" +<<<<<<< HEAD + case .mermaid: + return "Mermaid visualization" +======= +>>>>>>> main } } } @@ -105,6 +116,11 @@ struct ContentView: View { MiserableGraph() case .lattice: Lattice() +<<<<<<< HEAD + case .mermaid: + MermaidVisualization() +======= +>>>>>>> main } } } diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift index bd5ccc2..0338518 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift @@ -33,26 +33,44 @@ struct Lattice: View { @inlinable var body: some View { ForceDirectedGraph($isRunning) { +<<<<<<< HEAD + + Repeated(0..<(width*width)) { i in + let _i = Double(i / width) / Double(width) + let _j = Double(i % width) / Double(width) +======= ForEach(Array(0..<(width*width)), id:\.self) { i in let _i = Double(i / width) / Double(width) let _j = Double(i % width) / Double(width) +>>>>>>> main NodeMark(id: i, radius: 3.0) .foregroundStyle(Color(red: 1, green: _i, blue: _j)) .stroke() } +<<<<<<< HEAD + + Repeated(edge) { + LinkMark(from: $0.0, to: $0.1) + } + +======= for l in edge { LinkMark(from: l.0, to: l.1) } +>>>>>>> main } force: { LinkForce( originalLength: .constant(0.8), stiffness: .weightedByDegree(k: { _, _ in 1}) ) ManyBodyForce(strength: -0.8) +<<<<<<< HEAD +======= +>>>>>>> main } .toolbar { Button { @@ -62,5 +80,9 @@ struct Lattice: View { Text(isRunning ? "Pause" : "Start") } } +<<<<<<< HEAD + +======= +>>>>>>> main } } diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MermaidVisualization.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MermaidVisualization.swift new file mode 100644 index 0000000..6f4fd10 --- /dev/null +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MermaidVisualization.swift @@ -0,0 +1,122 @@ +// +// MermaidVisualization.swift +// ForceDirectedGraphExample +// +// Created by li3zhen1 on 1/6/24. +// + +import SwiftUI +import RegexBuilder +import Grape +import simd + +let multipleNodeRegex = Regex { + "{" + ZeroOrMore(.whitespace) + ZeroOrMore { + Capture (OneOrMore(.word)) + ZeroOrMore(.whitespace) + "," + ZeroOrMore(.whitespace) + } + Capture (OneOrMore(.word)) + ZeroOrMore(.whitespace) + "}" +} + +let singleNodeRegex = Regex { + Capture( OneOrMore(.word) ) +} + +let mermaidLinkRegex = Regex { + singleNodeRegex +// ChoiceOf { +// singleNodeRegex +// multipleNodeRegex +// } + OneOrMore(.whitespace) + ChoiceOf { + "-->" + "<--" + "—>" + "<—" + "->" + "<-" + } + + OneOrMore(.whitespace) + singleNodeRegex +} + +func parseMermaid( + _ text: String +) -> ([String], [(String, String)]) { + let links = text.split(separator: "\n") + .compactMap { + if let results = $0.matches(of: mermaidLinkRegex).first { + return (String(results.output.1), String(results.output.2)) + } + return nil + } + let nodes = Array(Set(links.flatMap { [$0.0, $0.1] })) + return (nodes, links) +} + +func getInitialPosition(id: String, r: Double) -> SIMD2 { + if let firstLetter = id.first?.unicodeScalars.first { + let deg = Double(firstLetter.value % 26) / 26 * 2 * .pi + return [cos(deg) * r, sin(deg) * r] + } + return .zero +} + +struct MermaidVisualization: View { + + @State private var text: String = """ + Alice --> Bob + Bob --> Cindy + Alice --> Dan + Alice --> Cindy + Tom --> Bob + Tom --> Kate + Kate --> Cindy + + """ + + var parsedGraph: ([String], [(String, String)]) { + parseMermaid(text) + } + + var body: some View { + ForceDirectedGraph { + Repeated(parsedGraph.0) { node in + NodeMark(id: node) + .symbol(RoundedRectangle(cornerSize: CGSize(width: 3, height: 3))) + .symbolSize(radius: 6) + .label(alignment: .bottom, offset: [0, 4]) { + Text(node) + } + } + Repeated(parsedGraph.1) { link in + LinkMark(from: link.0, to: link.1) + } + } force: { + ManyBodyForce() + LinkForce(originalLength: .constant(70)) + CenterForce() + } emittingNewNodesWithStates: { id in + KineticState(position: getInitialPosition(id: id, r: 100)) + } + .inspector(isPresented: .constant(true)) { + VStack { + Text("Edit the mermaid syntaxes to update the graph") + .font(.title) + TextEditor(text: $text) + .fontDesign(.monospaced) + + }.padding(.top) + } + + + } +} diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift index c8339ed..7ce6982 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift @@ -1,5 +1,9 @@ // +<<<<<<< HEAD +// Miserables.swift +======= // ForceDirectedGraphSwiftUIExample.swift +>>>>>>> main // ForceDirectedGraphExample // // Created by li3zhen1 on 11/5/23. @@ -8,6 +12,49 @@ import Foundation import Grape import SwiftUI +<<<<<<< HEAD +import Charts + + +struct MiserableGraph: View { + + private let graphData = getData(miserables) + + @State private var isRunning = false + @State private var opacity: Double = 0 + @State private var inspectorPresented = false + + var body: some View { + + ForceDirectedGraph($isRunning) { + + Repeated(graphData.nodes) { node in + NodeMark(id: node.id) + .symbol(.asterisk) + .symbolSize(radius: 9.0) + .stroke() + .foregroundStyle( + colors[node.group % colors.count] + .shadow( + .inner( + color: colors[node.group % colors.count].opacity(0.3), + radius: 3, + x: 0, + y: 1.5 + ) + ) + ) + .label(offset: [0.0, 12.0]) { + Text(node.id) + .font(.caption2) + } + } + + Repeated(graphData.links) { l in + LinkMark(from: l.source, to: l.target) + } +// +======= import ForceSimulation import Charts @@ -62,6 +109,7 @@ struct MiserableGraph: View { // } } } +>>>>>>> main } force: { ManyBodyForce(strength: -20) LinkForce( @@ -69,6 +117,24 @@ struct MiserableGraph: View { stiffness: .weightedByDegree(k: { _, _ in 1.0}) ) CenterForce() +<<<<<<< HEAD + } + .onNodeTapped { node in + inspectorPresented = true + } + .opacity(opacity) + .animation(.easeInOut, value: opacity) + .inspector(isPresented: $inspectorPresented) { + Text("Hello") + } + + .toolbar { + Button { + isRunning.toggle() + if opacity < 1 { + opacity = 1 + } +======= // CollideForce() } .onNodeTapped { @@ -77,6 +143,7 @@ struct MiserableGraph: View { .toolbar { Button { isRunning = !isRunning +>>>>>>> main } label: { Image(systemName: isRunning ? "pause.fill" : "play.fill") Text(isRunning ? "Pause" : "Start") diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift index adabf22..0d33565 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift @@ -35,7 +35,11 @@ struct MyRing: View { ForceDirectedGraph($isRunning) { +<<<<<<< HEAD + Repeated(0..<20) { i in +======= ForEach(Array(0..<20), id: \.self) { i in +>>>>>>> main NodeMark(id: 3 * i + 0) .symbol(.circle) .symbolSize(radius:4.0) @@ -49,12 +53,24 @@ struct MyRing: View { .symbolSize(radius:6.0) .foregroundStyle(.yellow) +<<<<<<< HEAD + + + LinkMark(from: 3 * i + 0, to: 3 * i + 1) + LinkMark(from: 3 * i + 1, to: 3 * i + 2) + + LinkMark(from: 3 * i + 0, to: 3 * ((i + 1) % 20) + 0) + LinkMark(from: 3 * i + 1, to: 3 * ((i + 1) % 20) + 1) + LinkMark(from: 3 * i + 2, to: 3 * ((i + 1) % 20) + 2) + +======= LinkMark(from: 3 * i + 0, to: 3 * i + 1) LinkMark(from: 3 * i + 1, to: 3 * i + 2) for j in 0..<3 { LinkMark(from: 3 * i + j, to: 3 * ((i + 1) % 20) + j) } +>>>>>>> main } } force: { ManyBodyForce(strength: -15) diff --git a/Package.swift b/Package.swift index ad1a53d..d810128 100644 --- a/Package.swift +++ b/Package.swift @@ -27,7 +27,7 @@ let package = Package( ], dependencies: [ - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.1.0") + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.3.0") ], targets: [ diff --git a/README.md b/README.md index 25ad803..a007254 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,19 @@ struct MyGraph: View { @State var isRunning = true // start moving once appeared. var body: some View { +<<<<<<< HEAD + ForceDirectedGraph(isRunning: $isRunning) { + + // 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: { +======= ForceDirectedGraph($isRunning) { // Declare nodes and links like you would do in Swift Charts. @@ -98,6 +111,7 @@ struct MyGraph: View { } } force: { +>>>>>>> main LinkForce() CenterForce() ManyBodyForce() diff --git a/Sources/ForceSimulation/ForceProtocol.swift b/Sources/ForceSimulation/ForceProtocol.swift index 3975c99..74ef48f 100644 --- a/Sources/ForceSimulation/ForceProtocol.swift +++ b/Sources/ForceSimulation/ForceProtocol.swift @@ -4,13 +4,6 @@ public protocol ForceProtocol { associatedtype Vector where Vector: SimulatableVector & L2NormCalculatable - - - /// Takes a simulation state and modifies its node positions and velocities. - /// This is executed in each tick of the simulation. - // @inlinable func apply() - - /// Takes a simulation state and modifies its node positions and velocities. /// This is executed in each tick of the simulation. @inlinable func apply(to kinetics: inout Kinetics) diff --git a/Sources/ForceSimulation/ForceSimulation.docc/theme-settings.json b/Sources/ForceSimulation/ForceSimulation.docc/theme-settings.json new file mode 100644 index 0000000..c3b5b09 --- /dev/null +++ b/Sources/ForceSimulation/ForceSimulation.docc/theme-settings.json @@ -0,0 +1,8 @@ +{ + "theme": { + "typography": { + "html-font": "system-ui, -apple-system, \"InterVar\"", + "html-font-mono": "ui-monospace, \"JetBrains Mono\", \"IBM Plex Mono\", monospace" + } + } +} \ No newline at end of file diff --git a/Sources/ForceSimulation/Forces/CenterForce.swift b/Sources/ForceSimulation/Forces/CenterForce.swift index a2aa165..aeb0849 100644 --- a/Sources/ForceSimulation/Forces/CenterForce.swift +++ b/Sources/ForceSimulation/Forces/CenterForce.swift @@ -6,13 +6,6 @@ extension Kinetics { /// See [Collide Force - D3](https://d3js.org/d3-force/collide). public struct CenterForce: ForceProtocol { - // @usableFromInline var kinetics: Kinetics! = nil - - @inlinable - public func apply() { - fatalError() - } - @inlinable public func apply(to kinetics: inout Kinetics) { var meanPosition = Vector.zero diff --git a/Sources/ForceSimulation/Forces/CollideForce.swift b/Sources/ForceSimulation/Forces/CollideForce.swift index 8dbf115..07bb637 100644 --- a/Sources/ForceSimulation/Forces/CollideForce.swift +++ b/Sources/ForceSimulation/Forces/CollideForce.swift @@ -90,125 +90,6 @@ extension Kinetics { internal var tree: UnsafeMutablePointer>>! = nil - @inlinable - public func apply() { - fatalError() - // assert(self.kinetics != nil, "Kinetics not bound to force") - - // let strength = self.strength - // let calculatedRadius = self.calculatedRadius!.mutablePointer - // let positionBufferPointer = kinetics.position.mutablePointer - // let velocityBufferPointer = kinetics.velocity.mutablePointer - - // let tree = self.tree! - - // for _ in 0...cover(of: self.kinetics.position) - - // tree.pointee.reset( - // rootBox: coveringBox, - // rootDelegate: .init(radiusBufferPointer: calculatedRadius) - // ) - // assert(tree.pointee.validCount == 1) - - // for p in kinetics.range { - // tree.pointee.add(nodeIndex: p, at: positionBufferPointer[p]) - // } - - // for i in kinetics.range { - // let iOriginalPosition = positionBufferPointer[i] - // let iOriginalVelocity = velocityBufferPointer[i] - // let iR = calculatedRadius[i] - // let iR2 = iR * iR - // let iPosition = iOriginalPosition + iOriginalVelocity - // let random = kinetics.randomGenerator - - // tree.pointee.visit { t in - - // let maxRadiusOfQuad = t.delegate.maxNodeRadius - // let deltaR = maxRadiusOfQuad + iR - - // if var jNode = t.nodeIndices { - // while true { - // let j = jNode.index - // // print("\(i)<=>\(j)") - // // is leaf, make sure every collision happens once. - // if j > i { - - // let jR = calculatedRadius[j] - // let jOriginalPosition = positionBufferPointer[j] - // let jOriginalVelocity = velocityBufferPointer[j] - // var deltaPosition = - // iPosition - (jOriginalPosition + jOriginalVelocity) - // let l = (deltaPosition).lengthSquared() - - // let deltaR = iR + jR - // if l < deltaR * deltaR { - - // var l = /*simd_length*/ (deltaPosition.jiggled(by: random)) - // .length() - // l = (deltaR - l) / l * strength - - // let jR2 = jR * jR - - // let k = jR2 / (iR2 + jR2) - - // deltaPosition *= l - - // velocityBufferPointer[i] += deltaPosition * k - // velocityBufferPointer[j] -= deltaPosition * (1 - k) - // } - // } - // if jNode.next == nil { - // break - // } else { - // jNode = jNode.next!.pointee - // } - // } - // return false - // } - - // // TODO: SIMD mask - - // // for laneIndex in t.box.p0.indices { - // // let _v = t.box.p0[laneIndex] - // // if _v > iPosition[laneIndex] + deltaR /* True if no overlap */ { - // // return false - // // } - // // } - - // // for laneIndex in t.box.p1.indices { - // // let _v = t.box.p1[laneIndex] - // // if _v < iPosition[laneIndex] - deltaR /* True if no overlap */ { - // // return false - // // } - // // } - - // let p0Flag = t.box.p0 .> (iPosition + deltaR) - // let p1Flag = t.box.p1 .< (iPosition - deltaR) - // let flag = p0Flag .| p1Flag - - // for laneIndex in t.box.p0.indices { - // if flag[laneIndex] { - // return false - // } - // // let _v = t.box.p1[laneIndex] - // // if (t.box.p0[laneIndex] > iPosition[laneIndex] + deltaR) - // // || (t.box.p1[laneIndex] < iPosition[laneIndex] - // // - deltaR) /* True if no overlap */ - // // { - // // return false - // // } - // } - // return true - // } - // } - // } - } - - - @inlinable public func apply(to kinetics: inout Kinetics) { diff --git a/Sources/ForceSimulation/Forces/CompositedForce.swift b/Sources/ForceSimulation/Forces/CompositedForce.swift index 81f0b5c..25f454d 100644 --- a/Sources/ForceSimulation/Forces/CompositedForce.swift +++ b/Sources/ForceSimulation/Forces/CompositedForce.swift @@ -14,12 +14,7 @@ where self.force1 = force1 self.force2 = force2 } - // @inlinable - // public func apply() { - // self.force1?.apply() - // self.force2.apply() - // } - + @inlinable public func apply(to kinetics: inout Kinetics) { self.force1?.apply(to: &kinetics) diff --git a/Sources/ForceSimulation/Forces/EmptyForce.swift b/Sources/ForceSimulation/Forces/EmptyForce.swift index 2dec42a..740d85e 100644 --- a/Sources/ForceSimulation/Forces/EmptyForce.swift +++ b/Sources/ForceSimulation/Forces/EmptyForce.swift @@ -1,8 +1,7 @@ extension Kinetics { public struct EmptyForce: ForceProtocol { - @inlinable - public func apply() {} + @inlinable public func apply(to kinetics: inout Kinetics) {} diff --git a/Sources/ForceSimulation/Forces/KDTreeForce.swift b/Sources/ForceSimulation/Forces/KDTreeForce.swift new file mode 100644 index 0000000..d164967 --- /dev/null +++ b/Sources/ForceSimulation/Forces/KDTreeForce.swift @@ -0,0 +1,104 @@ +// public protocol KDTreeForce: ForceProtocol +// where +// Vector: SimulatableVector & L2NormCalculatable +// { +// associatedtype Delegate: KDTreeDelegate where Delegate.Vector == Vector, Delegate.NodeID == Int + +// var kinetics: Kinetics! { get set } + +// func epilogue() +// func buildDelegate() -> Delegate +// func visitForeignTree( +// tree: inout KDTree, getDelegate: (D) -> Delegate) +// } + +// public struct CompositedKDTreeDelegate: KDTreeDelegate +// where +// V: SimulatableVector & L2NormCalculatable, +// D1: KDTreeDelegate, D2: KDTreeDelegate +// { +// var d1: D1 +// var d2: D2 + +// mutating public func didAddNode(_ node: Int, at position: V) { +// d1.didAddNode(node, at: position) +// d2.didAddNode(node, at: position) +// } + +// mutating public func didRemoveNode(_ node: Int, at position: V) { +// d1.didRemoveNode(node, at: position) +// d2.didRemoveNode(node, at: position) +// } + +// public func spawn() -> CompositedKDTreeDelegate { +// return .init(d1: d1.spawn(), d2: d2.spawn()) +// } + +// } + +// extension Kinetics.ManyBodyForce: KDTreeForce { +// public typealias Delegate = MassCentroidKDTreeDelegate + +// public func epilogue() { + +// } + +// public func buildDelegate() -> MassCentroidKDTreeDelegate { +// return .init(massProvider: { self.precalculatedMass[$0] }) +// } + +// public func visitForeignTree( +// tree: inout KDTree, getDelegate: (D) -> MassCentroidKDTreeDelegate +// ) { + +// } +// } + +// extension Kinetics.CollideForce: KDTreeForce { +// public typealias Delegate = MaxRadiusNDTreeDelegate + +// public func epilogue() { + +// } + +// public func buildDelegate() -> MaxRadiusNDTreeDelegate { +// return .init(radiusProvider: { self.calculatedRadius[$0] }) +// } + +// public func visitForeignTree( +// tree: inout KDTree, getDelegate: (D) -> MaxRadiusNDTreeDelegate +// ) where D: KDTreeDelegate, Vector == D.Vector, D.NodeID == Int { + +// } +// } + +// public struct CompositedKDTreeForce: ForceProtocol +// where +// KF1: KDTreeForce, KF2: KDTreeForce, +// Vector: SimulatableVector & L2NormCalculatable, +// KF1.Vector == Vector, KF2.Vector == Vector, KF1.Vector == Vector +// { +// var force1: KF1 +// var force2: KF2 + +// public func apply() { +// force1.epilogue() +// force2.epilogue() + +// var tree = KDTree>( +// covering: force1.kinetics!.position, +// rootDelegate: CompositedKDTreeDelegate( +// d1: force1.buildDelegate(), +// d2: force2.buildDelegate() +// ) +// ) + +// force1.visitForeignTree(tree: &tree, getDelegate: \.d1) +// force2.visitForeignTree(tree: &tree, getDelegate: \.d2) +// } + +// public mutating func bindKinetics(_ kinetics: Kinetics) { + +// } + +// } diff --git a/Sources/ForceSimulation/Forces/LinkForce.swift b/Sources/ForceSimulation/Forces/LinkForce.swift index c757461..a04f88c 100644 --- a/Sources/ForceSimulation/Forces/LinkForce.swift +++ b/Sources/ForceSimulation/Forces/LinkForce.swift @@ -65,44 +65,6 @@ extension Kinetics { @usableFromInline var calculatedBias: [Vector.Scalar] = [] - @inlinable - public func apply() { - fatalError() - // let positionBufferPointer = kinetics.position.mutablePointer - // let velocityBufferPointer = kinetics.velocity.mutablePointer - // let random = kinetics.randomGenerator - // for _ in 0...cover(of: self.kinetics.position) - // tree.pointee.reset(rootBox: coveringBox, rootDelegate: .init(massProvider: precalculatedMass)) - // for p in kinetics.range { - // tree.pointee.add(nodeIndex: p, at: positionBufferPointer[p]) - // } - - // for i in self.kinetics.range { - // let pos = positionBufferPointer[i] - // var f = Vector.zero - // tree.pointee.visit { t in - - // guard t.delegate.accumulatedCount > 0 else { return false } - // let centroid = - // t.delegate.accumulatedMassWeightedPositions / t.delegate.accumulatedMass - - // let vec = centroid - pos - // let boxWidth = (t.box.p1 - t.box.p0)[0] - // var distanceSquared = - // (vec - // // .jiggled() - // .jiggled(by: random)).lengthSquared() - - // let farEnough: Bool = - // (distanceSquared * theta2) > (boxWidth * boxWidth) - - // if distanceSquared < distanceMin2 { - // distanceSquared = (distanceMin2 * distanceSquared).squareRoot() - // } - - // if farEnough { - - // guard distanceSquared < distanceMax2 else { return true } - - // /// Workaround for "The compiler is unable to type-check this expression in reasonable time; try breaking up the expression into distinct sub-expressions" - // let k: Vector.Scalar = - // strength * alpha * t.delegate.accumulatedMass - // / distanceSquared // distanceSquared.squareRoot() - - // f += vec * k - // return false - - // } else if t.childrenBufferPointer != nil { - // return true - // } - - // if t.isFilledLeaf { - - // if t.nodeIndices!.contains(i) { return false } - - // let massAcc = t.delegate.accumulatedMass - - // let k: Vector.Scalar = strength * alpha * massAcc / distanceSquared // distanceSquared.squareRoot() - // f += vec * k - // return false - // } else { - // return true - // } - // } - - // positionBufferPointer[i] += f / precalculatedMass[i] - // } - } - - + diff --git a/Sources/ForceSimulation/Forces/PackedForce.swift b/Sources/ForceSimulation/Forces/PackedForce.swift new file mode 100644 index 0000000..376a55a --- /dev/null +++ b/Sources/ForceSimulation/Forces/PackedForce.swift @@ -0,0 +1,17 @@ +// struct PackedForce: ForceProtocol where Vector: SIMD, Vector.Scalar: FloatingPoint, repeat each Force: ForceProtocol { +// let forces: (repeat each Force) + +// // var kinetics: Kinetics? + +// init(forces: repeat each Force) { +// self.forces = (repeat each forces) +// } + +// func apply() { +// repeat (each forces).apply() +// } + +// func bindKinetics(_ kinetics: Kinetics) { +// repeat (each forces).bindKinetics(kinetics) +// } +// } \ No newline at end of file diff --git a/Sources/ForceSimulation/Forces/PositionForce.swift b/Sources/ForceSimulation/Forces/PositionForce.swift index cd5f465..a64e0b1 100644 --- a/Sources/ForceSimulation/Forces/PositionForce.swift +++ b/Sources/ForceSimulation/Forces/PositionForce.swift @@ -24,18 +24,6 @@ extension Kinetics { public var targetOnDirection: TargetOnDirection public var calculatedTargetOnDirection: UnsafeArray! = nil - @inlinable - public func apply() { - // assert(self.kinetics != nil, "Kinetics not bound to force") - // let alpha = kinetics.alpha - // let lane = self.direction - // for i in kinetics.range { - // kinetics.velocity[i][lane] += - // (self.calculatedTargetOnDirection[i] - kinetics.position[i][lane]) - // * self.calculatedStrength[i] * alpha - // } - } - @inlinable public func apply(to kinetics: inout Kinetics) { let alpha = kinetics.alpha diff --git a/Sources/ForceSimulation/Forces/RadialForce.swift b/Sources/ForceSimulation/Forces/RadialForce.swift index dfb805b..8c32a4c 100644 --- a/Sources/ForceSimulation/Forces/RadialForce.swift +++ b/Sources/ForceSimulation/Forces/RadialForce.swift @@ -21,21 +21,6 @@ extension Kinetics { @usableFromInline var calculatedStrength: UnsafeArray! = nil - @inlinable - public func apply() { - // assert(self.kinetics != nil, "Kinetics not bound to force") - - // let alpha = kinetics.alpha - // for i in kinetics.range { - // let nodeId = i - // let deltaPosition = (kinetics.position[i] - self.center).jiggled(by: kinetics.randomGenerator) //.jiggled() - // let r = (deltaPosition).length() - // let k = - // (self.calculatedRadius[nodeId] - // * self.calculatedStrength[nodeId] * alpha) / r - // kinetics.velocity[i] += deltaPosition * k - // } - } @inlinable public func apply(to kinetics: inout Kinetics) { diff --git a/Sources/ForceSimulation/Forces/SealedForce2D.swift b/Sources/ForceSimulation/Forces/SealedForce2D.swift index da39b46..3343c46 100644 --- a/Sources/ForceSimulation/Forces/SealedForce2D.swift +++ b/Sources/ForceSimulation/Forces/SealedForce2D.swift @@ -14,13 +14,6 @@ public struct SealedForce2D: Force2D { public var entries: [ForceEntry] = [] - @inlinable - public func apply() { - for force in self.entries { - force.apply() - } - } - @inlinable public func apply(to kinetics: inout Kinetics>) { for force in self.entries { @@ -79,26 +72,6 @@ public struct SealedForce2D: Force2D { case position(Kinetics2D.PositionForce) case empty - @inlinable - public func apply() { - switch self { - case .center(let force): - force.apply() - case .radial(let force): - force.apply() - case .manyBody(let force): - force.apply() - case .link(let force): - force.apply() - case .collide(let force): - force.apply() - case .position(let force): - force.apply() - default: - break - } - } - @inlinable public func dispose() { switch self { diff --git a/Sources/ForceSimulation/Forces/SealedForce3D.swift b/Sources/ForceSimulation/Forces/SealedForce3D.swift index 2a30afd..3077a2f 100644 --- a/Sources/ForceSimulation/Forces/SealedForce3D.swift +++ b/Sources/ForceSimulation/Forces/SealedForce3D.swift @@ -14,12 +14,6 @@ public struct SealedForce3D: Force3D { public var entries: [ForceEntry] = [] - @inlinable - public func apply() { - for force in self.entries { - force.apply() - } - } @inlinable public func apply(to kinetics: inout Kinetics>) { @@ -79,26 +73,6 @@ public struct SealedForce3D: Force3D { case position(Kinetics3D.PositionForce) case empty - @inlinable - public func apply() { - switch self { - case .center(let force): - force.apply() - case .radial(let force): - force.apply() - case .manyBody(let force): - force.apply() - case .link(let force): - force.apply() - case .collide(let force): - force.apply() - case .position(let force): - force.apply() - default: - break - } - } - @inlinable public func dispose() { switch self { diff --git a/Sources/ForceSimulation/Kinetics.swift b/Sources/ForceSimulation/Kinetics.swift index 72c24af..c40ca87 100644 --- a/Sources/ForceSimulation/Kinetics.swift +++ b/Sources/ForceSimulation/Kinetics.swift @@ -1,6 +1,6 @@ /// A class that holds the state of the simulation, which /// includes the positions, velocities of the nodes. -public struct Kinetics: Disposable +public struct Kinetics where Vector: SimulatableVector & L2NormCalculatable { /// The position of points stored in simulation. @@ -77,86 +77,6 @@ where Vector: SimulatableVector & L2NormCalculatable { self.randomGenerator = .init() } - // @inlinable - // init( - // links: [EdgeID], - // initialAlpha: Vector.Scalar, - // alphaMin: Vector.Scalar, - // alphaDecay: Vector.Scalar, - // alphaTarget: Vector.Scalar, - // velocityDecay: Vector.Scalar, - // position: consuming [Vector], - // velocity: consuming [Vector], - // fixation: consuming [Vector?], - // randomSeed: Vector.Scalar.Generator.OverflowingInteger - // ) { - // self.links = links - // self.initializedAlpha = initialAlpha - // self.alpha = initialAlpha - // self.alphaMin = alphaMin - // self.alphaDecay = alphaDecay - // self.alphaTarget = alphaTarget - // self.velocityDecay = velocityDecay - // let count = position.count - // self.validCount = count - - // self.position = UnsafeArray.createBuffer( - // withHeader: count, - // count: count, - // initialValue: .zero - // ) - - // self.velocity = UnsafeArray.createBuffer( - // withHeader: count, - // count: count, - // initialValue: .zero - // ) - // self.fixation = UnsafeArray.createBuffer( - // withHeader: count, - // count: count, - // initialValue: nil - // ) - - // self.randomGenerator = .allocate(capacity: 1) - // self.randomGenerator.initialize(to: .init(seed: randomSeed)) - // } - - // @inlinable - // internal func jigglePosition() { - // for i in range { - // position[i] = position[i].jiggled(by: self.randomGenerator) - // } - // } - - // @inlinable - // static func createZeros( - // links: [EdgeID], - // initialAlpha: Vector.Scalar, - // alphaMin: Vector.Scalar, - // alphaDecay: Vector.Scalar, - // alphaTarget: Vector.Scalar, - // velocityDecay: Vector.Scalar, - // count: Int - // ) -> Kinetics { - // return Kinetics( - // links: links, - // initialAlpha: initialAlpha, - // alphaMin: alphaMin, - // alphaDecay: alphaDecay, - // alphaTarget: alphaTarget, - // velocityDecay: velocityDecay, - - // position: Array(repeating: .zero, count: count), - // velocity: Array(repeating: .zero, count: count), - // fixation: Array(repeating: nil, count: count) - // ) - // } - - @inlinable - public func dispose() { - // self.randomGenerator.deinitialize(count: 1) - // self.randomGenerator.deallocate() - } } extension Kinetics { diff --git a/Sources/Grape/Contents/GraphContentBuilder.swift b/Sources/Grape/Contents/GraphContentBuilder.swift index e72e86b..eac8168 100644 --- a/Sources/Grape/Contents/GraphContentBuilder.swift +++ b/Sources/Grape/Contents/GraphContentBuilder.swift @@ -21,7 +21,7 @@ public struct GraphContentBuilder { /// Creates a list of graph contents from a for-loop. /// /// **Known issue**: - /// Type inference failes when the nested element is decorated with a modifier. + /// Type inference fails when the nested element is decorated with a modifier. /// /// **Workaround**: /// - Use `ForEach` like in SwiftUI. This is the recommended way. @@ -48,6 +48,7 @@ public struct GraphContentBuilder { /// MyComponent(i) /// } /// } + @available(*, deprecated, message: "Use `Repeated` instead. ") @inlinable public static func buildArray(_ components: [T]) -> some Content where T: Content, T.NodeID == NodeID { diff --git a/Sources/Grape/Contents/Repeated.swift b/Sources/Grape/Contents/Repeated.swift new file mode 100644 index 0000000..11042c2 --- /dev/null +++ b/Sources/Grape/Contents/Repeated.swift @@ -0,0 +1,27 @@ +public struct Repeated +where Data: RandomAccessCollection, Content: GraphContent, NodeID: Hashable { + + @usableFromInline + let data: Data + + @usableFromInline + let content: (Data.Element) -> Content + + @inlinable + public init( + _ data: Data, + @GraphContentBuilder graphContent: @escaping (Data.Element) -> Content + ) { + self.data = data + self.content = graphContent + } +} + +extension Repeated: GraphContent { + @inlinable + public func _attachToGraphRenderingContext(_ context: inout _GraphRenderingContext) { + self.data.forEach { element in + self.content(element)._attachToGraphRenderingContext(&context) + } + } +} diff --git a/Sources/Grape/Contents/_ForEach+GraphContent.swift b/Sources/Grape/Contents/_ForEach+GraphContent.swift index 7c185bf..ae4aa7e 100644 --- a/Sources/Grape/Contents/_ForEach+GraphContent.swift +++ b/Sources/Grape/Contents/_ForEach+GraphContent.swift @@ -37,7 +37,9 @@ extension _GraphContentWrapper: _GraphContentWrappingView { } @inlinable - internal static func pullback(id: KeyPath, _ content: @escaping (ID) -> InnerGraphContent) + internal static func pullback( + id: KeyPath, _ content: @escaping (ID) -> InnerGraphContent + ) -> (T) -> Self where ID: Hashable { return { element in @@ -60,6 +62,7 @@ extension ForEach: GraphContent where Content: GraphContent { extension ForEach where ID == Data.Element.ID, Content: _GraphContentWrappingView, Data.Element: Identifiable { @inlinable + // @_disfavoredOverload public init( _ data: Data, @GraphContentBuilder graphContent: @escaping (Data.Element) -> IG @@ -72,7 +75,6 @@ where ID == Data.Element.ID, Content: _GraphContentWrappingView, Data.Element: I let pb = _GraphContentWrapper.pullback(graphContent) self.init(data, content: pb) } - } extension ForEach where Content: _GraphContentWrappingView { @@ -92,3 +94,39 @@ extension ForEach where Content: _GraphContentWrappingView { self.init(data, id: id, content: pb) } } + +extension ForEach { + @inlinable + public init( + _ range: Range, + @GraphContentBuilder content: @escaping (Int) -> IG + ) + where + Data == Swift.Range, ID == Swift.Int, IG: GraphContent, NodeID: Hashable, + Content == _GraphContentWrapper + { + self.init(range, id: \.self, content: _GraphContentWrapper.pullback(content)) + } +} + +// extension ForEach +// where +// Data == Swift.Range, ID == Swift.Int, Content: _GraphContentWrappingView +// { +// @inlinable +// public init( +// _ data: RawData, +// @GraphContentBuilder graphContent: @escaping (RawData.Element) -> IG +// ) +// where +// IG: GraphContent, NodeID: Hashable, +// RawData: RandomAccessCollection, +// RawData.Indices == Swift.Range, +// Content == _GraphContentWrapper +// { + +// self.init(data.indices, id: \.self) { index in +// graphContent(data[index]) +// } +// } +// } diff --git a/Sources/Grape/Grape.docc/CreatingASimulationWithBuiltinForces.md b/Sources/Grape/Grape.docc/CreatingASimulationWithBuiltinForces.md new file mode 100644 index 0000000..fe98652 --- /dev/null +++ b/Sources/Grape/Grape.docc/CreatingASimulationWithBuiltinForces.md @@ -0,0 +1,70 @@ +# Creating a Simulation with Built-in Forces + + + +## Overview + + + +You can simply create simulations by using Simulation like this: + +```swift +import simd +import ForceSimulation + +// assuming you’re simulating 4 nodes +let nodeCount = 4 + +// Connect them +let links = [(0, 1), (1, 2), (2, 3), (3, 0)] + +/// Create a 2D force composited with 4 primitive forces. +let myForce = SealedForce2D { + // Forces are namespaced under `Kinetics` + // here we only use `Kinetics>`, i.e. `Kinetics2D` + Kinetics2D.ManyBodyForce(strength: -30) + Kinetics2D.LinkForce( + stiffness: .weightedByDegree(k: { _, _ in 1.0 }), + originalLength: .constant(35) + ) + Kinetics2D.CenterForce(center: .zero, strength: 1) + Kinetics2D.CollideForce(radius: .constant(3)) +} + +/// Create a simulation, the dimension is inferred from the force. +let mySimulation = Simulation( + nodeCount: nodeCount, + links: links.map { EdgeID(source: $0.0, target: $0.1) }, + forceField: myForce +) + +/// Force is ready to start! run `tick` to iterate the simulation. + +for mySimulation in 0..<120 { + mySimulation.tick() + let positions = mySimulation.kinetics.position.asArray() + /// Do something with the positions. +} +``` + +ForceSimulation module mainly contains 3 concepts, `Kinetics`, `ForceProtocol` and `Simulation`. + +@Image(source: "SimulationDiagram.svg", alt: "A diagram showing the relationships of `Kinetics`, `ForceProtocol` and `Simulation`. A `Simulation` contains a `Kinetics` and a `ForceProtocol`.") + +A diagram showing the relationships of `Kinetics`, `ForceProtocol` and `Simulation`. A `Simulation` contains a `Kinetics` and a `ForceProtocol`. + +- `Kinetics` describes all kinetic states of your system, i.e. position, velocity, link connections, and the variable alpha that describes how "active" your system is. `Vector` tells simulation how you decribe a coordinate in this space, it can be `SIMD2` or `SIMD3` or any other types conforming to `SimulatableVector`. + +- Forces are any types that conforms to `ForceProtocol`. This module provides most of the forces you will use in force directed graphs. And you can also create your own forces. They should be responsible for 2 tasks: + + - `bindKinetics(_ kinetics: Kinetics)`: binding to a Kinetics. In most cases the force should keep a reference of the Kinetics so they know what to mutate when apply is called. + + - `apply()`: Mutating the states of Kinetics. For example, a gravity force should add velocities on each node in this function. + +- Simulation is a shell class you interact with, which enables you to create any dimensional simulation with velocity Verlet integration. It manages a Kinetics and a force conforming to ``ForceProtocol``. Since Simulation only stores one force, you are responsible for compositing multiple forces into one. + +- Another data structure ``KDTree`` is used to accelerate the force simulation with Barnes-Hut Approximation. + +In this example, we run our simulation in a 2D space (`SIMD2`). We explicitly create a ``SealedForce2D`` to make sure the force is in the same dimension as the Kinetics. The `Vector` in `Simulation` is inferred from the force we pass. + +See [Examples](https://github.com/li3zhen1/Grape/tree/main/Examples) for example Xcode projects. \ No newline at end of file diff --git a/Sources/Grape/Grape.docc/Documentation.md b/Sources/Grape/Grape.docc/Documentation.md new file mode 100644 index 0000000..6be74ef --- /dev/null +++ b/Sources/Grape/Grape.docc/Documentation.md @@ -0,0 +1,29 @@ +# ``Grape`` + +Visualize force-directed graphs in SwiftUI. + +## Overview + +The `Grape` framework enables you to create a force simulation and visualize it in SwiftUI. It provides a set of handy interactions and styling options to help you visualize your graph-structured data. + +If you’re looking for a more detailed control of force-directed laouts, please refer to [ForceSimulation | Documentation](https://li3zhen1.github.io/Grape/ForceSimulation/documentation/ForceSimulation/). + + +## Topics + +### Creating a graph visualization + +* ``ForceDirectedGraph`` + + +### Describing a graph +* ``GraphContent`` +* ``NodeMark`` +* ``LinkMark`` +* ``Repeated`` +* ``GraphContent/foregroundStyle(_:)`` + +### Handling gestures and events + + +### Managing the states \ No newline at end of file diff --git a/Sources/Grape/Grape.docc/Resources/ForceDirectedGraph.png b/Sources/Grape/Grape.docc/Resources/ForceDirectedGraph.png new file mode 100644 index 0000000..774c604 Binary files /dev/null and b/Sources/Grape/Grape.docc/Resources/ForceDirectedGraph.png differ diff --git a/Sources/Grape/Grape.docc/Resources/SimulationDiagram.svg b/Sources/Grape/Grape.docc/Resources/SimulationDiagram.svg new file mode 100644 index 0000000..39e6c2e --- /dev/null +++ b/Sources/Grape/Grape.docc/Resources/SimulationDiagram.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Sources/Grape/Grape.docc/theme-settings.json b/Sources/Grape/Grape.docc/theme-settings.json new file mode 100644 index 0000000..c3b5b09 --- /dev/null +++ b/Sources/Grape/Grape.docc/theme-settings.json @@ -0,0 +1,8 @@ +{ + "theme": { + "typography": { + "html-font": "system-ui, -apple-system, \"InterVar\"", + "html-font-mono": "ui-monospace, \"JetBrains Mono\", \"IBM Plex Mono\", monospace" + } + } +} \ No newline at end of file diff --git a/Sources/Grape/Modifiers/Effects/GrapeEffect.ForegroundStyle.swift b/Sources/Grape/Modifiers/Effects/GrapeEffect.ForegroundStyle.swift index 9cce7dc..b8e8eaa 100644 --- a/Sources/Grape/Modifiers/Effects/GrapeEffect.ForegroundStyle.swift +++ b/Sources/Grape/Modifiers/Effects/GrapeEffect.ForegroundStyle.swift @@ -22,8 +22,17 @@ extension GraphContentEffect { self.shading = shading } } -} + @usableFromInline + internal struct ShadingBy { + @usableFromInline + let value: AnyHashable + @inlinable + public init(by value: T) { + self.value = value + } + } +} extension GraphContentEffect.ForegroundStyle: GraphContentModifier { @inlinable @@ -36,7 +45,8 @@ extension GraphContentEffect.ForegroundStyle: GraphContentModifier { } @inlinable - public func _exit(_ context: inout _GraphRenderingContext) where NodeID : Hashable { + public func _exit(_ context: inout _GraphRenderingContext) + where NodeID: Hashable { context.states.shading.removeLast() // context.operations.append( // .updateShading(context.states.currentShading) @@ -53,7 +63,8 @@ extension GraphContentEffect.Shading: GraphContentModifier { } @inlinable - public func _exit(_ context: inout _GraphRenderingContext) where NodeID : Hashable { + public func _exit(_ context: inout _GraphRenderingContext) + where NodeID: Hashable { context.states.shading.removeLast() } -} \ No newline at end of file +} diff --git a/Sources/Grape/Modifiers/Effects/GrapeEffect.Label.swift b/Sources/Grape/Modifiers/Effects/GrapeEffect.Label.swift index ce9dd7e..25a3610 100644 --- a/Sources/Grape/Modifiers/Effects/GrapeEffect.Label.swift +++ b/Sources/Grape/Modifiers/Effects/GrapeEffect.Label.swift @@ -25,6 +25,29 @@ extension GraphContentEffect { } } + @usableFromInline + internal struct RichLabel { + + @usableFromInline + let view: AnyView + + @usableFromInline + let alignment: Alignment + + @usableFromInline + let offset: CGVector + + @inlinable + public init( + _ view: some View, + alignment: Alignment = .bottom, + offset: CGVector = .zero + ) { + self.view = .init(erasing: view) + self.alignment = alignment + self.offset = offset + } + } } extension GraphContentEffect.Label: GraphContentModifier { @@ -60,6 +83,23 @@ extension GraphContentEffect.Label: GraphContentModifier { } } + +extension GraphContentEffect.RichLabel: GraphContentModifier { + @inlinable + public func _into( + _ context: inout _GraphRenderingContext + ) where NodeID: Hashable { + + } + + @inlinable + @MainActor + public func _exit(_ context: inout _GraphRenderingContext) + where NodeID: Hashable { + + } +} + extension Alignment { @inlinable internal func anchorOffset(for size: CGSize) -> SIMD2 { diff --git a/Sources/Grape/Modifiers/GraphContent+GraphContentModifiers.swift b/Sources/Grape/Modifiers/GraphContent+GraphContentModifiers.swift index 85ec316..e2a8f9d 100644 --- a/Sources/Grape/Modifiers/GraphContent+GraphContentModifiers.swift +++ b/Sources/Grape/Modifiers/GraphContent+GraphContentModifiers.swift @@ -35,6 +35,11 @@ extension GraphContent { return ModifiedGraphContent(self, GraphContentEffect.SymbolSize(size)) } + @inlinable + public func symbolSize(_ size: SIMD2) -> some GraphContent { + return ModifiedGraphContent(self, GraphContentEffect.SymbolSize(size.cgSize)) + } + @inlinable public func symbolSize(radius: CGFloat) -> some GraphContent { return ModifiedGraphContent( @@ -59,6 +64,14 @@ extension GraphContent { self, GraphContentEffect.Label(text, alignment: alignment, offset: offset)) } + @inlinable + public func label( + _ text: Text?, alignment: Alignment = .bottom, offset: SIMD2 = .zero + ) -> some GraphContent { + + return label(text, alignment: alignment, offset: offset.cgVector) + } + @inlinable public func label( _ string: String?, alignment: Alignment = .bottom, offset: CGVector = .zero @@ -69,11 +82,45 @@ extension GraphContent { @inlinable public func label( - _ alignment: Alignment = .bottom, offset: CGVector = .zero, @ViewBuilder _ text: () -> Text? + _ string: String?, alignment: Alignment = .bottom, offset: SIMD2 = .zero + ) -> some GraphContent { + return label(string, alignment: alignment, offset: offset.cgVector) + } + + @inlinable + public func label( + _ alignment: Alignment = .bottom, offset: CGVector = .zero, + @ViewBuilder _ content: () -> Text? ) -> some GraphContent { return ModifiedGraphContent( - self, GraphContentEffect.Label(text(), alignment: alignment, offset: offset)) + self, GraphContentEffect.Label(content(), alignment: alignment, offset: offset)) + } + + @inlinable + public func label( + alignment: Alignment = .bottom, offset: SIMD2 = .zero, + @ViewBuilder _ content: () -> Text? + ) -> some GraphContent { + return label(alignment, offset: offset.cgVector, content) + } + + @inlinable + public func richLabel( + _ alignment: Alignment = .bottom, offset: CGVector = .zero, + @ViewBuilder _ content: () -> some View + ) -> some GraphContent { + + return ModifiedGraphContent( + self, GraphContentEffect.RichLabel(content(), alignment: alignment, offset: offset)) + } + + @inlinable + public func richLabel( + alignment: Alignment = .bottom, offset: SIMD2 = .zero, + @ViewBuilder _ content: () -> some View + ) -> some GraphContent { + return richLabel(alignment, offset: offset.cgVector, content) } /// Sets the stroke style for this graph content. diff --git a/Sources/Grape/Modifiers/GraphForegroundScale.swift b/Sources/Grape/Modifiers/GraphForegroundScale.swift new file mode 100644 index 0000000..59aa841 --- /dev/null +++ b/Sources/Grape/Modifiers/GraphForegroundScale.swift @@ -0,0 +1,49 @@ +import SwiftUI + +@usableFromInline +enum GrapeEnvironment { } + +extension GrapeEnvironment { + @usableFromInline + struct GraphForegroundScale: EnvironmentKey { + @usableFromInline + static let defaultValue: [AnyHashable: GraphicsContext.Shading] = [:] + } +} + +extension EnvironmentValues { + @inlinable + var graphForegroundScaleEnvironment: GrapeEnvironment.GraphForegroundScale.Value { + get { self[GrapeEnvironment.GraphForegroundScale.self] } + set { self[GrapeEnvironment.GraphForegroundScale.self] = newValue } + } +} + +@usableFromInline +struct GraphEnvironmentViewModifier: ViewModifier { + + @usableFromInline + let colorScale: Dictionary + + @inlinable + init(_ mapping: KeyValuePairs) where S: ShapeStyle, DataValue: Hashable { + var colorScale: [AnyHashable: GraphicsContext.Shading] = [:] + mapping.forEach { + colorScale[.init($0.0)] = .style($0.1) + } + self.colorScale = colorScale + } + + @inlinable + func body(content: Content) -> some View { + content + .environment(\.graphForegroundScaleEnvironment, colorScale) + } +} + +extension View { + @inlinable + func graphForegroundStyleScale(_ mapping: KeyValuePairs) -> some View where S: ShapeStyle, DataValue: Hashable { + return modifier(GraphEnvironmentViewModifier(mapping)) + } +} diff --git a/Sources/Grape/Utils/LinkShape.swift b/Sources/Grape/Utils/LinkShape.swift new file mode 100644 index 0000000..1f5e83a --- /dev/null +++ b/Sources/Grape/Utils/LinkShape.swift @@ -0,0 +1,64 @@ +import SwiftUI + +public protocol LinkShape { + @inlinable + func path(from: CGPoint, to: CGPoint) -> Path + + @inlinable + func decoration(from: CGPoint, to: CGPoint) -> Path? +} + +extension LinkShape { + @inlinable + public func decoration(from: CGPoint, to: CGPoint) -> Path? { nil } +} + +public protocol StraightLineLinkShape: LinkShape { } + +extension LinkShape where Self: StraightLineLinkShape { + @inlinable + public func path(from: CGPoint, to: CGPoint) -> Path { + Path { path in + path.move(to: from) + path.addLine(to: to) + } + } +} + +public struct PlainLineLink: LinkShape, StraightLineLinkShape { } + +public struct ArrowLineLink: LinkShape { + @usableFromInline + let arrowSize: CGSize + + @usableFromInline + let arrowAngle: CGFloat + + @usableFromInline + let arrowCornerRadius: CGFloat + + @inlinable + public func path(from: CGPoint, to: CGPoint) -> Path { + Path { + path in + let angle = atan2(to.y - from.y, to.x - from.x) + let arrowPoint = CGPoint( + x: to.x - arrowSize.width * cos(angle), + y: to.y - arrowSize.height * sin(angle) + ) + path.move(to: from) + path.addLine(to: arrowPoint) + path.addLine( + to: CGPoint( + x: arrowPoint.x - arrowSize.width * cos(angle + arrowAngle), + y: arrowPoint.y - arrowSize.height * sin(angle + arrowAngle) + )) + path.move(to: arrowPoint) + path.addLine( + to: CGPoint( + x: arrowPoint.x - arrowSize.width * cos(angle - arrowAngle), + y: arrowPoint.y - arrowSize.height * sin(angle - arrowAngle) + )) + } + } +} diff --git a/Sources/Grape/Views/ForceDirectedGraph+Gesture.swift b/Sources/Grape/Views/ForceDirectedGraph+Gesture.swift index 4c29a24..4f9483c 100644 --- a/Sources/Grape/Views/ForceDirectedGraph+Gesture.swift +++ b/Sources/Grape/Views/ForceDirectedGraph+Gesture.swift @@ -24,7 +24,7 @@ extension ForceDirectedGraph { return } - if model.simulationContext.storage.kinetics.alpha > Self.minimumAlphaAfterDrag { + if model.simulationContext.storage.kinetics.alpha < Self.minimumAlphaAfterDrag { model.simulationContext.storage.kinetics.alpha = Self.minimumAlphaAfterDrag } @@ -54,7 +54,7 @@ extension ForceDirectedGraph { } return } - if model.simulationContext.storage.kinetics.alpha > Self.minimumAlphaAfterDrag { + if model.simulationContext.storage.kinetics.alpha < Self.minimumAlphaAfterDrag { model.simulationContext.storage.kinetics.alpha = Self.minimumAlphaAfterDrag } diff --git a/Sources/Grape/Views/ForceDirectedGraph+View.swift b/Sources/Grape/Views/ForceDirectedGraph+View.swift index d12c671..8471a2d 100644 --- a/Sources/Grape/Views/ForceDirectedGraph+View.swift +++ b/Sources/Grape/Views/ForceDirectedGraph+View.swift @@ -26,6 +26,11 @@ extension ForceDirectedGraph: View { self.model.stop() } } + .onAppear { + if self.isRunning { + self.model.start() + } + } } // #if DEBUG @@ -87,7 +92,7 @@ extension ForceDirectedGraph: View { extension ForceDirectedGraph: Equatable { @inlinable - public static func == (lhs: ForceDirectedGraph, rhs: ForceDirectedGraph) -> Bool + public static func == (lhs: Self, rhs: Self) -> Bool { return lhs._graphRenderingContextShadow == rhs._graphRenderingContextShadow // && lhs._forceDescriptors == rhs._forceDescriptors diff --git a/Sources/Grape/Views/ForceDirectedGraph.swift b/Sources/Grape/Views/ForceDirectedGraph.swift index 074e77d..236e8c1 100644 --- a/Sources/Grape/Views/ForceDirectedGraph.swift +++ b/Sources/Grape/Views/ForceDirectedGraph.swift @@ -1,11 +1,25 @@ import ForceSimulation import SwiftUI -public struct ForceDirectedGraph { +public struct ForceDirectedGraph where NodeID == Content.NodeID { + + // public typealias NodeID = Content.NodeID @inlinable @Environment(\.self) - var environmentValues: EnvironmentValues + var environment: EnvironmentValues + + @inlinable + @Environment(\.graphForegroundScaleEnvironment) + var graphForegroundScale + + @inlinable + @Environment(\.colorScheme) + var colorScheme + + @inlinable + @Environment(\.colorSchemeContrast) + var colorSchemeContrast // the copy of the graph context to be used for comparison in `onChange` // should be not used for rendering @@ -22,7 +36,7 @@ public struct ForceDirectedGraph { // @State @inlinable - var model: ForceDirectedGraphModel + var model: ForceDirectedGraphModel { @storageRestrictions(initializes: _model) init(initialValue) { @@ -33,7 +47,7 @@ public struct ForceDirectedGraph { } @usableFromInline - var _model: State> + var _model: State> @inlinable var isRunning: Bool { @@ -52,7 +66,8 @@ public struct ForceDirectedGraph { public init( _ _isRunning: Binding = .constant(true), ticksPerSecond: Double = 60.0, - @GraphContentBuilder _ graph: () -> some GraphContent, + initialViewportTransform: ViewportTransform = .identity, + @GraphContentBuilder _ graph: () -> Content, @SealedForce2DBuilder force: () -> [SealedForce2D.ForceEntry] = { [] }, emittingNewNodesWithStates state: @escaping (NodeID) -> KineticState = { _ in .init(position: .zero) diff --git a/Sources/Grape/Views/ForceDirectedGraphModel.swift b/Sources/Grape/Views/ForceDirectedGraphModel.swift index 8780007..3a7acb6 100644 --- a/Sources/Grape/Views/ForceDirectedGraphModel.swift +++ b/Sources/Grape/Views/ForceDirectedGraphModel.swift @@ -4,7 +4,9 @@ import Observation import SwiftUI // @Observable -public final class ForceDirectedGraphModel { +public final class ForceDirectedGraphModel { + + public typealias NodeID = Content.NodeID @usableFromInline var graphRenderingContext: _GraphRenderingContext @@ -191,8 +193,11 @@ extension StrokeStyle { extension ForceDirectedGraphModel { @inlinable - func start() { + func start(minAlpha: Double = 0.6) { guard self.scheduledTimer == nil else { return } + if simulationContext.storage.kinetics.alpha < minAlpha { + simulationContext.storage.kinetics.alpha = minAlpha + } self.scheduledTimer = Timer.scheduledTimer( withTimeInterval: 1.0 / ticksPerSecond, repeats: true @@ -356,9 +361,9 @@ extension ForceDirectedGraphModel { antialias: Self.textRasterizationAntialias ) lastRasterizedScaleFactor = env.displayScale - graphRenderingContext.symbols[resolvedTextContent] = .resolved(cgImage) + graphRenderingContext.symbols[resolvedTextContent] = .resolved(consume text, cgImage) rasterizedSymbol = cgImage - case .resolved(let cgImage): + case .resolved(_, let cgImage): rasterizedSymbol = cgImage } diff --git a/Sources/Grape/Views/GraphLayoutInputs.swift b/Sources/Grape/Views/GraphLayoutInputs.swift new file mode 100644 index 0000000..d455d3d --- /dev/null +++ b/Sources/Grape/Views/GraphLayoutInputs.swift @@ -0,0 +1,3 @@ +struct GraphLayoutInputs { + +} \ No newline at end of file diff --git a/Sources/Grape/Views/GraphRenderingContext.swift b/Sources/Grape/Views/GraphRenderingContext.swift index c35b51f..21522b4 100644 --- a/Sources/Grape/Views/GraphRenderingContext.swift +++ b/Sources/Grape/Views/GraphRenderingContext.swift @@ -4,7 +4,7 @@ public struct _GraphRenderingContext { @usableFromInline enum TextResolvingStatus: Equatable { case pending(Text) - case resolved(CGImage?) + case resolved(Text, CGImage?) } @usableFromInline @@ -33,6 +33,12 @@ public struct _GraphRenderingContext { @usableFromInline internal var states = GraphRenderingStates() + + + @inlinable + func updateEnvironment(with newEnvironment: EnvironmentValues) { + + } } extension _GraphRenderingContext: Equatable { diff --git a/Tests/ForceSimulationTests/ForceTests.swift b/Tests/ForceSimulationTests/ForceTests.swift index 4a95899..a6081b5 100644 --- a/Tests/ForceSimulationTests/ForceTests.swift +++ b/Tests/ForceSimulationTests/ForceTests.swift @@ -22,7 +22,7 @@ final class ForceTests: XCTestCase { forceField: myForce ) - for i in 0...10 { + for _ in 0...10 { simulation.tick() } diff --git a/Tests/GrapeTests/ContentBuilderTests.swift b/Tests/GrapeTests/ContentBuilderTests.swift new file mode 100644 index 0000000..928eb40 --- /dev/null +++ b/Tests/GrapeTests/ContentBuilderTests.swift @@ -0,0 +1,63 @@ + +import XCTest +import simd +import SwiftUI + +@testable import Grape + + + +final class ContentBuilderTests: XCTestCase { + func buildGraph( + @GraphContentBuilder _ builder: () -> some GraphContent + ) -> some GraphContent where NodeID: Hashable { + let result = builder() + return result + } + + func testForLoop() { + let _ = buildGraph { + Repeated(0..<10) { i in + NodeMark(id: i) + } + } + } + + func testMixed() { + let _ = buildGraph { + LinkMark(from: 0, to: 1) + + NodeMark(id: 3) + NodeMark(id: 4) + NodeMark(id: 5) + } + } + + func testConditional() { + // let _ = buildGraph { + // if true { + // NodeMark(id: 0) + // } else { + // NodeMark(id: 1) + // } + // } + } + + struct ID: Identifiable { + var id: Int + } + + func testForEach() { + let _ = [ + ID(id: 0), + ID(id: 1), + ID(id: 2), + ] + + // let _ = buildGraph { + // ForEach(data: arr) { i in + // NodeMark(id: i.id) + // } + // } + } +} \ No newline at end of file diff --git a/Tests/GrapeTests/GraphContentBuilderTests.swift b/Tests/GrapeTests/GraphContentBuilderTests.swift index 664998d..9c7eecf 100644 --- a/Tests/GrapeTests/GraphContentBuilderTests.swift +++ b/Tests/GrapeTests/GraphContentBuilderTests.swift @@ -25,20 +25,20 @@ final class GraphContentBuilderTests: XCTestCase { ID(id: 2), ] - let a = ForEach(arr) { i in + let a = Repeated(arr) { i in NodeMark(id: i.id) } let b = buildGraph { NodeMark(id: 0) - ForEach(arr) { i in + Repeated(arr) { i in NodeMark(id: i.id) } } let c = buildGraph { NodeMark(id: 0) - for i in 0..<10 { + Repeated(0..<10) { i in NodeMark(id: 0) } }