From df0963e19a2faf42f9009b61e996c268c7554157 Mon Sep 17 00:00:00 2001 From: "Li, Zhen" Date: Wed, 21 Feb 2024 15:10:50 -0500 Subject: [PATCH 1/2] Implement isRunning --- .../Miserables.swift | 75 +++++++++++------ .../Grape/Views/ForceDirectedGraph+View.swift | 15 +--- Sources/Grape/Views/ForceDirectedGraph.swift | 5 ++ .../Grape/Views/ForceDirectedGraphModel.swift | 80 ++++++++++++++++--- .../Grape/Views/ForceDirectedGraphState.swift | 68 ++++++++++++++++ 5 files changed, 191 insertions(+), 52 deletions(-) create mode 100644 Sources/Grape/Views/ForceDirectedGraphState.swift diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift index 808083c..0ab351b 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift @@ -16,11 +16,13 @@ struct MiserableGraph: View { private let graphData = getData(miserables) @State private var isRunning = false - @State private var opacity: Double = 0 + @State private var opacity: Double = 0.6 @State private var inspectorPresented = false @State private var modelTransform: ViewportTransform = .identity.scale(by: 2.0) + @State private var stateMixin = ForceDirectedGraphState(initialIsRunning: false) + @ViewBuilder func getLabel(_ text: String) -> some View { Text(text) @@ -39,7 +41,8 @@ struct MiserableGraph: View { ForceDirectedGraph( $isRunning, - $modelTransform + $modelTransform, + stateMixin: stateMixin ) { Series(graphData.nodes) { node in @@ -47,20 +50,20 @@ struct MiserableGraph: View { .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 - ) - ) - ) - .richLabel(node.id, offset: .zero) { - self.getLabel(node.id) - } +// .foregroundStyle( +// colors[node.group % colors.count] +// .shadow( +// .inner( +// color: colors[node.group % colors.count].opacity(0.3), +// radius: 3, +// x: 0, +// y: 1.5 +// ) +// ) +// ) +// .richLabel(node.id, offset: .zero) { +// self.getLabel(node.id) +// } } Series(graphData.links) { l in @@ -83,16 +86,38 @@ struct MiserableGraph: View { .ignoresSafeArea() .toolbar { - Text("\(modelTransform.scale)") - Button { - isRunning.toggle() - if opacity < 1 { - opacity = 1 - } - } label: { - Image(systemName: isRunning ? "pause.fill" : "play.fill") - Text(isRunning ? "Pause" : "Start") + MiserableToolbarContent(stateMixin: stateMixin, opacity: $opacity) + } + } +} + +struct MiserableToolbarContent: View { + @Bindable var stateMixin: ForceDirectedGraphState + @Binding var opacity: Double + + init(stateMixin: ForceDirectedGraphState, opacity: Binding) { + self.stateMixin = stateMixin + self._opacity = opacity + } + + var body: some View { + + + Button { + stateMixin.modelTransform.scaling(by: 1.1) + } label: { + Text("\(stateMixin.modelTransform.scale)") + } + + Button { + stateMixin.isRunning.toggle() + // print(stateMixin.isRunning) + if opacity < 1 { + opacity = 1 } + } label: { + Image(systemName: stateMixin.isRunning ? "pause.fill" : "play.fill") + Text(stateMixin.isRunning ? "Pause" : "Start") } } } diff --git a/Sources/Grape/Views/ForceDirectedGraph+View.swift b/Sources/Grape/Views/ForceDirectedGraph+View.swift index 9fc30e3..a28c5bb 100644 --- a/Sources/Grape/Views/ForceDirectedGraph+View.swift +++ b/Sources/Grape/Views/ForceDirectedGraph+View.swift @@ -22,21 +22,8 @@ extension ForceDirectedGraph: View { alpha: self.model.simulationContext.storage.kinetics.alpha ) } - .onChange( - of: self.isRunning, - initial: false - ) { oldValue, newValue in - guard oldValue != newValue else { return } - if newValue { - self.model.start() - } else { - self.model.stop() - } - } .onAppear { - if self.isRunning { - self.model.start() - } + self.model.trackStateMixin() } } diff --git a/Sources/Grape/Views/ForceDirectedGraph.swift b/Sources/Grape/Views/ForceDirectedGraph.swift index f284ff9..8cfe8fb 100644 --- a/Sources/Grape/Views/ForceDirectedGraph.swift +++ b/Sources/Grape/Views/ForceDirectedGraph.swift @@ -84,6 +84,7 @@ where NodeID == Content.NodeID { public init( _ isRunning: Binding = .constant(true), _ modelTransform: Binding = .constant(.identity), + stateMixin: ForceDirectedGraphState = ForceDirectedGraphState(), ticksPerSecond: Double = 60.0, initialViewportTransform: ViewportTransform = .identity, @GraphContentBuilder _ graph: () -> Content, @@ -105,9 +106,13 @@ where NodeID == Content.NodeID { self.model = .init( gctx, force, + stateMixin: stateMixin, modelTransform: modelTransform, emittingNewNodesWith: state, ticksPerSecond: ticksPerSecond ) + + } + } diff --git a/Sources/Grape/Views/ForceDirectedGraphModel.swift b/Sources/Grape/Views/ForceDirectedGraphModel.swift index 70f16c2..131f2cd 100644 --- a/Sources/Grape/Views/ForceDirectedGraphModel.swift +++ b/Sources/Grape/Views/ForceDirectedGraphModel.swift @@ -58,14 +58,13 @@ public final class ForceDirectedGraphModel { @inlinable var isDragStartStateRecorded: Bool { - return draggingNodeID != nil || backgroundDragStart != nil + return draggingNodeID != nil || backgroundDragStart != nil } // records the transform right before a magnification gesture starts @usableFromInline var lastTransformRecord: ViewportTransform? = nil - @usableFromInline let velocityDecay: Double @@ -148,15 +147,18 @@ public final class ForceDirectedGraphModel { @usableFromInline var _onGraphMagnified: (() -> Void)? = nil - // // records the transform right before a magnification gesture starts @usableFromInline var obsoleteState = ObsoleteState(cgSize: .zero) + @usableFromInline + internal var stateMixinRef: ForceDirectedGraphState + @inlinable init( _ graphRenderingContext: _GraphRenderingContext, - _ forceField: consuming SealedForce2D, + _ forceField: SealedForce2D, + stateMixin: ForceDirectedGraphState, modelTransform: Binding, emittingNewNodesWith: @escaping (NodeID) -> KineticState = { _ in .init(position: .zero) @@ -169,28 +171,30 @@ public final class ForceDirectedGraphModel { self._emittingNewNodesWith = emittingNewNodesWith self.velocityDecay = velocityDecay let _simulationContext = SimulationContext.create( - for: consume graphRenderingContext, - with: consume forceField, - velocityDecay: consume velocityDecay + for: graphRenderingContext, + with: forceField, + velocityDecay: velocityDecay ) _simulationContext.updateAllKineticStates(emittingNewNodesWith) - self.simulationContext = consume _simulationContext + self.simulationContext = _simulationContext self.viewportPositions = .createUninitializedBuffer( count: self.simulationContext.storage.kinetics.position.count ) self.currentFrame = 0 -// self.lastViewportSize = .zero + self._modelTransformExtenalBinding = modelTransform self.modelTransform = modelTransform.wrappedValue + self.stateMixinRef = stateMixin } @inlinable convenience init( _ graphRenderingContext: _GraphRenderingContext, - _ forceField: consuming SealedForce2D, + _ forceField: SealedForce2D, + stateMixin: ForceDirectedGraphState, modelTransform: Binding, emittingNewNodesWith: @escaping (NodeID) -> KineticState = { _ in .init(position: .zero) @@ -200,6 +204,7 @@ public final class ForceDirectedGraphModel { self.init( graphRenderingContext, forceField, + stateMixin: stateMixin, modelTransform: modelTransform, emittingNewNodesWith: emittingNewNodesWith, ticksPerSecond: ticksPerSecond, @@ -207,9 +212,54 @@ public final class ForceDirectedGraphModel { ) } + @inlinable + func trackStateMixin() { + if stateMixinRef.isRunning { + start() + } else { + stop() + } + continuouslyTrackingRunningStateMixin() + } + + @inlinable + func continuouslyTrackingRunningStateMixin() { + withObservationTracking { + updateModelRunningState(isRunning: stateMixinRef.isRunning) + } onChange: { + Task { @MainActor [weak self] in + self?.continuouslyTrackingRunningStateMixin() + } + } + } + + @inlinable + func continuouslyTrackingTransformStateMixin() { + withObservationTracking { + + } onChange: { + Task { @MainActor [weak self] in + self?.continuouslyTrackingTransformStateMixin() + } + } + } + + @inlinable + func updateModelRunningState(isRunning: Bool) { + if stateMixinRef.isRunning { + DispatchQueue.main.async { [weak self] in + self?.start() + } + } else { + DispatchQueue.main.async { [weak self] in + self?.stop() + } + } + } + @inlinable deinit { - stop() + self.stop() } @usableFromInline @@ -240,7 +290,9 @@ extension StrokeStyle { extension ForceDirectedGraphModel { @inlinable + // @MainActor func start(minAlpha: Double = 0.6) { + print("Into start") guard self.scheduledTimer == nil else { return } if simulationContext.storage.kinetics.alpha < minAlpha { simulationContext.storage.kinetics.alpha = minAlpha @@ -254,6 +306,7 @@ extension ForceDirectedGraphModel { } @inlinable + // @MainActor func tick() { withMutation(keyPath: \.currentFrame) { simulationContext.storage.tick() @@ -263,12 +316,13 @@ extension ForceDirectedGraphModel { } @inlinable + // @MainActor func stop() { + print("Into stop") self.scheduledTimer?.invalidate() self.scheduledTimer = nil } - @inlinable @MainActor func render( @@ -496,7 +550,7 @@ extension ForceDirectedGraphModel { graphRenderingContext.resolvedViews[symbolID] = .resolved(view, resolved) rasterizedSymbol = resolved case .resolved(_, let cgImage): - + rasterizedSymbol = cgImage } diff --git a/Sources/Grape/Views/ForceDirectedGraphState.swift b/Sources/Grape/Views/ForceDirectedGraphState.swift new file mode 100644 index 0000000..128568a --- /dev/null +++ b/Sources/Grape/Views/ForceDirectedGraphState.swift @@ -0,0 +1,68 @@ +import Observation + +public class ForceDirectedGraphState: Observation.Observable { + + @usableFromInline + internal var _$modelTransform: ViewportTransform + + + @usableFromInline + internal var _$isRunning: Bool + + + + @inlinable + public var modelTransform: ViewportTransform { + get { + access(keyPath: \.modelTransform) + return _$modelTransform + } + set { + withMutation(keyPath: \.modelTransform) { + _$modelTransform = newValue + } + } + } + + @inlinable + public var isRunning: Bool { + get { + access(keyPath: \.isRunning) + return _$isRunning + } + set { + withMutation(keyPath: \.isRunning) { + _$isRunning = newValue + } + } + } + + @inlinable + public init( + initialIsRunning: Bool = true, + initialModelTransform: ViewportTransform = .identity + ) { + self._$modelTransform = initialModelTransform + self._$isRunning = initialIsRunning + } + + // MARK: - Observation + + @usableFromInline + let _$observationRegistrar = Observation.ObservationRegistrar() + + @inlinable + nonisolated func access( + keyPath: KeyPath + ) { + _$observationRegistrar.access(self, keyPath: keyPath) + } + + @inlinable + nonisolated func withMutation( + keyPath: KeyPath, + _ mutation: () throws -> MutationResult + ) rethrows -> MutationResult { + try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation) + } +} From 2374701285a38619a6563e1be7ecc6e5a03ca83e Mon Sep 17 00:00:00 2001 From: li3zhen1 Date: Thu, 22 Feb 2024 23:43:40 -0500 Subject: [PATCH 2/2] [State Management] Add bidirection state management for running state and transformation. --- .../project.pbxproj | 4 + .../ContentView.swift | 2 +- .../GraphStateToolbar.swift | 38 ++++++++++ .../ForceDirectedGraphExample/Lattice.swift | 14 ++-- .../MermaidVisualization.swift | 2 +- .../Miserables.swift | 73 +++++++++---------- .../ForceDirectedGraphExample/MyRing.swift | 13 +--- README.md | 15 +++- .../Utils/SimulatableVector.swift | 1 + .../Views/ForceDirectedGraph+Gesture.swift | 4 +- Sources/Grape/Views/ForceDirectedGraph.swift | 41 +++++------ .../Grape/Views/ForceDirectedGraphModel.swift | 53 ++++++++------ .../Grape/Views/ForceDirectedGraphState.swift | 45 ++++++------ 13 files changed, 173 insertions(+), 132 deletions(-) create mode 100644 Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/GraphStateToolbar.swift diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj index ece790d..812de4f 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ B717595B2AFBFDBD000DF006 /* Lattice.swift in Sources */ = {isa = PBXBuildFile; fileRef = B717595A2AFBFDBD000DF006 /* Lattice.swift */; }; B762092F2B49FCD000476B93 /* MermaidVisualization.swift in Sources */ = {isa = PBXBuildFile; fileRef = B762092E2B49FCD000476B93 /* MermaidVisualization.swift */; }; B780DD7A2AF84ECB001C605F /* MyRing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B780DD792AF84ECB001C605F /* MyRing.swift */; }; + B79012AE2B88474F008F4C03 /* GraphStateToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79012AD2B88474F008F4C03 /* GraphStateToolbar.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 */; }; @@ -26,6 +27,7 @@ B717595A2AFBFDBD000DF006 /* Lattice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lattice.swift; sourceTree = ""; }; B762092E2B49FCD000476B93 /* MermaidVisualization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MermaidVisualization.swift; sourceTree = ""; }; B780DD792AF84ECB001C605F /* MyRing.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MyRing.swift; sourceTree = ""; }; + B79012AD2B88474F008F4C03 /* GraphStateToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphStateToolbar.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 = ""; }; @@ -78,6 +80,7 @@ B71759582AFBFC4B000DF006 /* Miserables.swift */, B717595A2AFBFDBD000DF006 /* Lattice.swift */, B762092E2B49FCD000476B93 /* MermaidVisualization.swift */, + B79012AD2B88474F008F4C03 /* GraphStateToolbar.swift */, ); path = ForceDirectedGraphExample; sourceTree = ""; @@ -168,6 +171,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + B79012AE2B88474F008F4C03 /* GraphStateToolbar.swift in Sources */, B717595B2AFBFDBD000DF006 /* Lattice.swift in Sources */, B780DD7A2AF84ECB001C605F /* MyRing.swift in Sources */, B7AFA55D2ADF4997009C7154 /* ContentView.swift in Sources */, diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ContentView.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ContentView.swift index fb58551..3698951 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ContentView.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/ContentView.swift @@ -54,7 +54,7 @@ extension ExampleKind { struct ContentView: View { - @State var selection: ExampleKind? = .classicMiserable + @State var selection: ExampleKind? = .ring var body: some View { diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/GraphStateToolbar.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/GraphStateToolbar.swift new file mode 100644 index 0000000..bac8ede --- /dev/null +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/GraphStateToolbar.swift @@ -0,0 +1,38 @@ +// +// GraphStateToolbar.swift +// ForceDirectedGraphExample +// +// Created by li3zhen1 on 2/22/24. +// + +import Foundation +import SwiftUI +import Grape + +struct GraphStateToggle: View { + @Bindable var graphStates: ForceDirectedGraphState + var body: some View { + + Group { + Button { + graphStates.modelTransform.scaling(by: 0.9) + } label: { + Image(systemName: "minus") + } + Text(String(format:"Scale: %.2f", graphStates.modelTransform.scale)) + .fontDesign(.monospaced) + Button { + graphStates.modelTransform.scaling(by: 1.1) + } label: { + Image(systemName: "plus") + } + } + + Button { + graphStates.isRunning.toggle() + } label: { + Image(systemName: graphStates.isRunning ? "pause.fill" : "play.fill") + Text(graphStates.isRunning ? "Pause" : "Start") + } + } +} diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift index c74c094..90e7c17 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift @@ -13,7 +13,10 @@ struct Lattice: View { let width = 20 let edge: [(Int, Int)] - @State var isRunning = false + + @State var graphStates = ForceDirectedGraphState( + initialIsRunning: true + ) init() { var edge = [(Int, Int)]() @@ -32,7 +35,7 @@ struct Lattice: View { @inlinable var body: some View { - ForceDirectedGraph($isRunning) { + ForceDirectedGraph(states: graphStates) { Series(0..<(width*width)) { i in let _i = Double(i / width) / Double(width) @@ -54,12 +57,7 @@ struct Lattice: View { ManyBodyForce(strength: -0.8) } .toolbar { - Button { - isRunning = !isRunning - } label: { - Image(systemName: isRunning ? "pause.fill" : "play.fill") - Text(isRunning ? "Pause" : "Start") - } + GraphStateToggle(graphStates: graphStates) } } diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MermaidVisualization.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MermaidVisualization.swift index deef174..32a2fd4 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MermaidVisualization.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MermaidVisualization.swift @@ -100,7 +100,7 @@ struct MermaidVisualization: View { tappedNode = $0 } .ignoresSafeArea() - #if !os(xrOS) + #if !os(visionOS) .inspector(isPresented: .constant(true)) { VStack { Text("Tapped: \(tappedNode ?? "nil")") diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift index 0ab351b..6fc053e 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Miserables.swift @@ -15,23 +15,26 @@ struct MiserableGraph: View { private let graphData = getData(miserables) - @State private var isRunning = false - @State private var opacity: Double = 0.6 @State private var inspectorPresented = false - @State private var modelTransform: ViewportTransform = .identity.scale(by: 2.0) - @State private var stateMixin = ForceDirectedGraphState(initialIsRunning: false) + @State private var stateMixin = ForceDirectedGraphState( + initialIsRunning: false, + initialModelTransform: .identity.scale(by: 1.2) + ) + + @State private var opacity = 0.0 @ViewBuilder func getLabel(_ text: String) -> some View { Text(text) + .foregroundStyle(.background) .font(.caption2) .padding(.vertical, 2.0) .padding(.horizontal, 6.0) .background(alignment: .center) { RoundedRectangle(cornerSize: .init(width: 12, height: 12)) - .fill(.white) + .fill(.foreground) .shadow(radius: 1.5, y: 1.0) } .padding() @@ -40,30 +43,17 @@ struct MiserableGraph: View { var body: some View { ForceDirectedGraph( - $isRunning, - $modelTransform, - stateMixin: stateMixin + states: stateMixin ) { Series(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 -// ) -// ) -// ) -// .richLabel(node.id, offset: .zero) { -// self.getLabel(node.id) -// } + .symbol(.circle) + .symbolSize(radius: 8.0) + .stroke() + .richLabel(node.id, offset: .zero) { + self.getLabel(node.id) + } } Series(graphData.links) { l in @@ -78,9 +68,6 @@ struct MiserableGraph: View { stiffness: .weightedByDegree(k: { _, _ in 1.0}) ) } - .onNodeTapped { node in - inspectorPresented = true - } .opacity(opacity) .animation(.easeInOut, value: opacity) @@ -95,23 +82,29 @@ struct MiserableToolbarContent: View { @Bindable var stateMixin: ForceDirectedGraphState @Binding var opacity: Double - init(stateMixin: ForceDirectedGraphState, opacity: Binding) { - self.stateMixin = stateMixin - self._opacity = opacity - } - var body: some View { - - - Button { - stateMixin.modelTransform.scaling(by: 1.1) - } label: { - Text("\(stateMixin.modelTransform.scale)") + Group { + Button { + stateMixin.modelTransform.scaling(by: 1.1) + } label: { + Image(systemName: "minus") + } + Button { + stateMixin.modelTransform.scaling(by: 1.1) + } label: { + Text(String(format:"Scale: %.2f", stateMixin.modelTransform.scale)) + .fontDesign(.monospaced) + } + Button { + stateMixin.modelTransform.scaling(by: 0.9) + } label: { + Image(systemName: "plus") + } } + Button { stateMixin.isRunning.toggle() - // print(stateMixin.isRunning) if opacity < 1 { opacity = 1 } diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift index 46388d0..c35f5d9 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift @@ -10,15 +10,13 @@ import Grape import SwiftUI import ForceSimulation - - struct MyRing: View { - @State var isRunning = false + @State var graphStates = ForceDirectedGraphState() var body: some View { - ForceDirectedGraph($isRunning) { + ForceDirectedGraph(states: graphStates) { Series(0..<20) { i in NodeMark(id: 3 * i + 0) .symbol(.circle) @@ -51,12 +49,7 @@ struct MyRing: View { CollideForce() } .toolbar { - Button { - isRunning = !isRunning - } label: { - Image(systemName: isRunning ? "pause.fill" : "play.fill") - Text(isRunning ? "Pause" : "Start") - } + GraphStateToggle(graphStates: graphStates) } } } diff --git a/README.md b/README.md index e31fb04..93d7f3a 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ To use Grape in a [SwiftPM](https://swift.org/package-manager/) project, add thi ``` swift dependencies: [ - .package(url: "https://github.com/li3zhen1/Grape", from: "0.6.1") + .package(url: "https://github.com/li3zhen1/Grape", from: "0.7.0") ] ``` @@ -114,6 +114,10 @@ dependencies: [ .product(name: "Grape", package: "Grape"), ``` +> [!NOTE] +> The `Grape` module relies on [`Observation` framework](https://developer.apple.com/documentation/observation). For backdeployment, you may want to check some community shims like [`swift-perception`](https://github.com/pointfreeco/swift-perception). +> The `Grape` module may introduce breaking API changes in minor version changes before 1.0 release. The `ForceSimulation` module is stable in terms of public API now. +

@@ -125,6 +129,7 @@ Grape ships 2 modules: - The `Grape` module allows you to create force-directed graphs in SwiftUI Views. - The `ForceSimulation` module is the underlying mechanism of `Grape`, and it helps you to create more complicated or customized force simulations. It also contains a `KDTree` data structure built with performance in mind, which can be useful for spatial partitioning tasks. +
### The `Grape` module @@ -136,10 +141,13 @@ For detailed usage, please refer to [documentation](https://li3zhen1.github.io/G import Grape struct MyGraph: View { - @State var isRunning = true // start moving once appeared. + + // States including running status, transformation, etc. + // Gives you a handle to control the states. + @State var graphStates = ForceDirectedGraphState() var body: some View { - ForceDirectedGraph($isRunning) { + ForceDirectedGraph(states: graphStates) { // Declare nodes and links like you would do in Swift Charts. NodeMark(id: 0).foregroundStyle(.green) @@ -160,6 +168,7 @@ struct MyGraph: View { ``` +
diff --git a/Sources/ForceSimulation/Utils/SimulatableVector.swift b/Sources/ForceSimulation/Utils/SimulatableVector.swift index 203c1bc..4d759c1 100644 --- a/Sources/ForceSimulation/Utils/SimulatableVector.swift +++ b/Sources/ForceSimulation/Utils/SimulatableVector.swift @@ -110,6 +110,7 @@ extension SIMD2: L2NormCalculatable where Scalar == Double { } } + extension SIMD3: L2NormCalculatable where Scalar == Float { @inlinable public func distanceSquared(to point: SIMD3) -> Scalar { diff --git a/Sources/Grape/Views/ForceDirectedGraph+Gesture.swift b/Sources/Grape/Views/ForceDirectedGraph+Gesture.swift index c7bf4e5..bc865a1 100644 --- a/Sources/Grape/Views/ForceDirectedGraph+Gesture.swift +++ b/Sources/Grape/Views/ForceDirectedGraph+Gesture.swift @@ -98,10 +98,10 @@ import SwiftUI static var minimumScaleDelta: CGFloat { 0.001 } @inlinable - static var minimumScale: CGFloat { 0.25 } + static var minimumScale: CGFloat { 1e-4 } @inlinable - static var maximumScale: CGFloat { 4.0 } + static var maximumScale: CGFloat { .infinity } @inlinable static var magnificationDecay: CGFloat { 0.1 } diff --git a/Sources/Grape/Views/ForceDirectedGraph.swift b/Sources/Grape/Views/ForceDirectedGraph.swift index 8cfe8fb..803bcab 100644 --- a/Sources/Grape/Views/ForceDirectedGraph.swift +++ b/Sources/Grape/Views/ForceDirectedGraph.swift @@ -50,18 +50,18 @@ where NodeID == Content.NodeID { @usableFromInline internal var _model: State> - @inlinable - internal var isRunning: Bool { - get { - _isRunning.wrappedValue - } - set { - _isRunning.wrappedValue = newValue - } - } - - @usableFromInline - internal var _isRunning: Binding +// @inlinable +// internal var isRunning: Bool { +// get { +// _isRunning.wrappedValue +// } +// set { +// _isRunning.wrappedValue = newValue +// } +// } + +// @usableFromInline +// internal var _isRunning: Binding // @inlinable // internal var modelTransform: ViewportTransform { @@ -82,9 +82,9 @@ where NodeID == Content.NodeID { @inlinable public init( - _ isRunning: Binding = .constant(true), - _ modelTransform: Binding = .constant(.identity), - stateMixin: ForceDirectedGraphState = ForceDirectedGraphState(), +// _ isRunning: Binding = .constant(true), +// _ modelTransform: Binding = .constant(.identity), + states: ForceDirectedGraphState = ForceDirectedGraphState(), ticksPerSecond: Double = 60.0, initialViewportTransform: ViewportTransform = .identity, @GraphContentBuilder _ graph: () -> Content, @@ -98,7 +98,7 @@ where NodeID == Content.NodeID { graph()._attachToGraphRenderingContext(&gctx) self._graphRenderingContextShadow = gctx - self._isRunning = isRunning +// self._isRunning = isRunning self._forceDescriptors = force() @@ -106,13 +106,12 @@ where NodeID == Content.NodeID { self.model = .init( gctx, force, - stateMixin: stateMixin, - modelTransform: modelTransform, + stateMixin: states, +// modelTransform: modelTransform, emittingNewNodesWith: state, ticksPerSecond: ticksPerSecond ) - - + } -} +} \ No newline at end of file diff --git a/Sources/Grape/Views/ForceDirectedGraphModel.swift b/Sources/Grape/Views/ForceDirectedGraphModel.swift index 131f2cd..36abebe 100644 --- a/Sources/Grape/Views/ForceDirectedGraphModel.swift +++ b/Sources/Grape/Views/ForceDirectedGraphModel.swift @@ -20,27 +20,33 @@ public final class ForceDirectedGraphModel { @usableFromInline var simulationContext: SimulationContext - @usableFromInline - internal var _modelTransform: ViewportTransform + // @usableFromInline + // internal var _modelTransform: ViewportTransform + // { + // didSet { + // stateMixinRef.modelTransform = modelTransform + // } + // } - @usableFromInline - internal var _modelTransformExtenalBinding: Binding +// @usableFromInline +// internal var _modelTransformExtenalBinding: Binding @inlinable internal var modelTransform: ViewportTransform { - @storageRestrictions(initializes: _modelTransform) - init(initialValue) { - _modelTransform = initialValue - } + // @storageRestrictions(initializes: _modelTransform) + // init(initialValue) { + // _modelTransform = initialValue + // } get { - return _modelTransform + stateMixinRef.modelTransform } set { - _modelTransform = newValue - _modelTransformExtenalBinding.wrappedValue = newValue + // _modelTransform = newValue + stateMixinRef.modelTransform = newValue } + } /// Moves the zero-centered simulation to final view @@ -159,7 +165,7 @@ public final class ForceDirectedGraphModel { _ graphRenderingContext: _GraphRenderingContext, _ forceField: SealedForce2D, stateMixin: ForceDirectedGraphState, - modelTransform: Binding, +// modelTransform: Binding, emittingNewNodesWith: @escaping (NodeID) -> KineticState = { _ in .init(position: .zero) }, @@ -184,10 +190,8 @@ public final class ForceDirectedGraphModel { count: self.simulationContext.storage.kinetics.position.count ) self.currentFrame = 0 - - self._modelTransformExtenalBinding = modelTransform - self.modelTransform = modelTransform.wrappedValue self.stateMixinRef = stateMixin + // self._modelTransform = stateMixin.modelTransform } @inlinable @@ -195,7 +199,7 @@ public final class ForceDirectedGraphModel { _ graphRenderingContext: _GraphRenderingContext, _ forceField: SealedForce2D, stateMixin: ForceDirectedGraphState, - modelTransform: Binding, +// modelTransform: Binding, emittingNewNodesWith: @escaping (NodeID) -> KineticState = { _ in .init(position: .zero) }, @@ -205,7 +209,7 @@ public final class ForceDirectedGraphModel { graphRenderingContext, forceField, stateMixin: stateMixin, - modelTransform: modelTransform, +// modelTransform: modelTransform, emittingNewNodesWith: emittingNewNodesWith, ticksPerSecond: ticksPerSecond, velocityDecay: 30 / ticksPerSecond @@ -219,27 +223,30 @@ public final class ForceDirectedGraphModel { } else { stop() } - continuouslyTrackingRunningStateMixin() + continuouslyTrackingRunning() + continuouslyTrackingTransform() } @inlinable - func continuouslyTrackingRunningStateMixin() { + func continuouslyTrackingRunning() { withObservationTracking { updateModelRunningState(isRunning: stateMixinRef.isRunning) } onChange: { Task { @MainActor [weak self] in - self?.continuouslyTrackingRunningStateMixin() + self?.continuouslyTrackingRunning() } } } @inlinable - func continuouslyTrackingTransformStateMixin() { + func continuouslyTrackingTransform() { withObservationTracking { - + // FIXME: mutation cycle? + _ = stateMixinRef.modelTransform + // stateMixinRef.access(keyPath: \.modelTransform) } onChange: { Task { @MainActor [weak self] in - self?.continuouslyTrackingTransformStateMixin() + self?.continuouslyTrackingTransform() } } } diff --git a/Sources/Grape/Views/ForceDirectedGraphState.swift b/Sources/Grape/Views/ForceDirectedGraphState.swift index 128568a..e270cc4 100644 --- a/Sources/Grape/Views/ForceDirectedGraphState.swift +++ b/Sources/Grape/Views/ForceDirectedGraphState.swift @@ -1,24 +1,37 @@ import Observation +// public typealias ForceDirectedGraphState = ForceDirectedGraphMixedState + +// extension ForceDirectedGraphMixedState where Mixin == Void { +// @inlinable +// convenience init( +// initialIsRunning: Bool = true, +// initialModelTransform: ViewportTransform = .identity +// ) { +// self.init( +// initialMixin: (), +// initialIsRunning: initialIsRunning, +// initialModelTransform: initialModelTransform +// ) +// } +// } + public class ForceDirectedGraphState: Observation.Observable { @usableFromInline internal var _$modelTransform: ViewportTransform - @usableFromInline internal var _$isRunning: Bool - - @inlinable public var modelTransform: ViewportTransform { get { - access(keyPath: \.modelTransform) + _reg.access(self, keyPath: \.modelTransform) return _$modelTransform } set { - withMutation(keyPath: \.modelTransform) { + _reg.withMutation(of: self, keyPath: \.modelTransform) { _$modelTransform = newValue } } @@ -27,11 +40,11 @@ public class ForceDirectedGraphState: Observation.Observable { @inlinable public var isRunning: Bool { get { - access(keyPath: \.isRunning) + _reg.access(self, keyPath: \.isRunning) return _$isRunning } set { - withMutation(keyPath: \.isRunning) { + _reg.withMutation(of: self, keyPath: \.isRunning) { _$isRunning = newValue } } @@ -42,6 +55,7 @@ public class ForceDirectedGraphState: Observation.Observable { initialIsRunning: Bool = true, initialModelTransform: ViewportTransform = .identity ) { + self._reg = Observation.ObservationRegistrar() self._$modelTransform = initialModelTransform self._$isRunning = initialIsRunning } @@ -49,20 +63,5 @@ public class ForceDirectedGraphState: Observation.Observable { // MARK: - Observation @usableFromInline - let _$observationRegistrar = Observation.ObservationRegistrar() - - @inlinable - nonisolated func access( - keyPath: KeyPath - ) { - _$observationRegistrar.access(self, keyPath: keyPath) - } - - @inlinable - nonisolated func withMutation( - keyPath: KeyPath, - _ mutation: () throws -> MutationResult - ) rethrows -> MutationResult { - try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation) - } + let _reg: Observation.ObservationRegistrar }