From 850fcb78edb743e3c889ef86e919d8f33e2f46e2 Mon Sep 17 00:00:00 2001 From: Carlo Rapisarda Date: Sat, 21 Dec 2024 12:30:37 +0100 Subject: [PATCH 1/3] Fix findNode method by scaling node radius with viewport transform --- .../Grape/Views/ForceDirectedGraphModel.findNode.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift b/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift index fdbfe48..9424109 100644 --- a/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift +++ b/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift @@ -7,6 +7,9 @@ extension ForceDirectedGraphModel { internal func findNode( at locationInSimulationCoordinate: SIMD2 ) -> NodeID? { + + let viewportScale = self.finalTransform.scale + for i in simulationContext.storage.kinetics.range.reversed() { let iNodeID = simulationContext.nodeIndices[i] guard @@ -16,9 +19,10 @@ extension ForceDirectedGraphModel { else { continue } let iPos = simulationContext.storage.kinetics.position[i] - - if simd_length_squared(locationInSimulationCoordinate - iPos) <= iRadius2 - { + let scaledRadius2 = iRadius2 / max(.ulpOfOne, (8.0 * viewportScale * viewportScale)) + let length2 = simd_length_squared(locationInSimulationCoordinate - iPos) + + if length2 <= scaledRadius2 { return iNodeID } } From 875eaafd8ba955bb1a76286f76aade15f4d51015 Mon Sep 17 00:00:00 2001 From: Zhen Li Date: Sat, 28 Dec 2024 16:08:08 -0800 Subject: [PATCH 2/3] Unify size calculation for marks with and without specified symbolShape --- .../ForceDirectedGraphExample/Lattice.swift | 2 +- .../ForceDirectedGraphExample/MyRing.swift | 10 +- Sources/Grape/Contents/NodeMark.swift | 38 +------- .../ForceDirectedGraphModel.findNode.swift | 8 ++ .../Grape/Views/ForceDirectedGraphModel.swift | 91 ++++++++----------- .../Grape/Views/GraphRenderingStates.swift | 8 +- Sources/Grape/Views/RenderOperation.swift | 15 ++- 7 files changed, 75 insertions(+), 97 deletions(-) diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift index 90e7c17..cc1297a 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/Lattice.swift @@ -40,7 +40,7 @@ struct Lattice: View { Series(0..<(width*width)) { i in let _i = Double(i / width) / Double(width) let _j = Double(i % width) / Double(width) - NodeMark(id: i, radius: 3.0) + NodeMark(id: i) .foregroundStyle(Color(red: 1, green: _i, blue: _j)) .stroke() } diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift index 021923d..4b46f28 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift @@ -19,17 +19,19 @@ struct MyRing: View { ForceDirectedGraph(states: graphStates) { Series(0..<20) { i in NodeMark(id: 3 * i + 0) - .symbol(.circle) - .symbolSize(radius:4.0) + .symbolSize(radius: 6.0) .foregroundStyle(.green) + .stroke(.clear) NodeMark(id: 3 * i + 1) .symbol(.pentagon) .symbolSize(radius:5.0) .foregroundStyle(.blue) + .stroke(.clear) NodeMark(id: 3 * i + 2) .symbol(.circle) .symbolSize(radius:6.0) .foregroundStyle(.yellow) + .stroke(.clear) LinkMark(from: 3 * i + 0, to: 3 * i + 1) LinkMark(from: 3 * i + 1, to: 3 * i + 2) @@ -37,9 +39,11 @@ struct MyRing: View { 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) - .stroke(.black, StrokeStyle(lineWidth: 2.0, lineCap: .round, lineJoin: .round)) + } + .stroke(.black, StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)) + } force: { ManyBodyForce(strength: -15) LinkForce( diff --git a/Sources/Grape/Contents/NodeMark.swift b/Sources/Grape/Contents/NodeMark.swift index 7e006ec..d8644d6 100644 --- a/Sources/Grape/Contents/NodeMark.swift +++ b/Sources/Grape/Contents/NodeMark.swift @@ -1,38 +1,15 @@ import SwiftUI import simd -public struct NodeMark: GraphContent & Identifiable { - - // public enum LabelDisplayStrategy { - // case auto - // case specified(Bool) - // case byPageRank((Double) -> Bool) - // } - - // public enum LabelPositioning { - // case bottomOfMark - // case topOfMark - // case startAfterMark - // case endBeforeMark - // } +public struct NodeMark: GraphContent, Identifiable, Equatable { public var id: NodeID - // public var fill: Color - // public var strokeColor: Color? - // public var strokeWidth: Double - public var radius: Double - // public var label: String? - // public var labelColor: Color - // public var labelDisplayStrategy: LabelDisplayStrategy - // public var labelPositioning: LabelPositioning @inlinable public init( - id: NodeID, - radius: Double = 4.0 + id: NodeID ) { self.id = id - self.radius = radius } @inlinable @@ -42,7 +19,7 @@ public struct NodeMark: GraphContent & Identifiable { self, context.states.currentShading, context.states.currentStroke, - context.states.currentSymbolShape + context.states.currentSymbolShapeOrSize ) ) context.states.currentID = .node(id) @@ -56,11 +33,4 @@ extension NodeMark: CustomDebugStringConvertible { public var debugDescription: String { return "Node(id: \(id))" } -} - -extension NodeMark: Equatable { - @inlinable - public static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.id == rhs.id && lhs.radius == rhs.radius - } -} +} \ No newline at end of file diff --git a/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift b/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift index 9424109..5f016ec 100644 --- a/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift +++ b/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift @@ -19,6 +19,14 @@ extension ForceDirectedGraphModel { else { continue } let iPos = simulationContext.storage.kinetics.position[i] + /// https://github.com/li3zhen1/Grape/pull/62#issue-2753932460 + /// + /// ```swift + /// let actualRadius = pow((iRadius2 * 0.5), 0.5) * 0.5 + /// let scaledRadius = actualRadius / max(.ulpOfOne, viewportScale) + /// let scaledRadius2 = pow(scaledRadius, 2.0) + /// ``` + /// let scaledRadius2 = iRadius2 / max(.ulpOfOne, (8.0 * viewportScale * viewportScale)) let length2 = simd_length_squared(locationInSimulationCoordinate - iPos) diff --git a/Sources/Grape/Views/ForceDirectedGraphModel.swift b/Sources/Grape/Views/ForceDirectedGraphModel.swift index 8068cd3..2cbc920 100644 --- a/Sources/Grape/Views/ForceDirectedGraphModel.swift +++ b/Sources/Grape/Views/ForceDirectedGraphModel.swift @@ -116,7 +116,7 @@ public final class ForceDirectedGraphModel { let ticksPerSecond: Double @usableFromInline -// @MainActor + // @MainActor var scheduledTimer: Timer? = nil @usableFromInline @@ -360,8 +360,10 @@ extension ForceDirectedGraphModel { let p = if let pathBuilder = op.path { { - let sourceNodeRadius = sqrt(graphRenderingContext.nodeRadiusSquaredLookup[op.mark.id.source] ?? 0) / 2 - let targetNodeRadius = sqrt(graphRenderingContext.nodeRadiusSquaredLookup[op.mark.id.target] ?? 0) / 2 + let sourceNodeRadius = + sqrt(graphRenderingContext.nodeRadiusSquaredLookup[op.mark.id.source] ?? 0) / 2 + let targetNodeRadius = + sqrt(graphRenderingContext.nodeRadiusSquaredLookup[op.mark.id.target] ?? 0) / 2 let angle = atan2(targetPos.y - sourcePos.y, targetPos.x - sourcePos.x) let sourceOffset = SIMD2( cos(angle) * sourceNodeRadius, sin(angle) * sourceNodeRadius @@ -405,60 +407,41 @@ extension ForceDirectedGraphModel { continue } let pos = viewportPositions[id] - if let path = op.path { - graphicsContext.transform = .init(translationX: pos.x, y: pos.y) - graphicsContext.fill( - path, - with: op.fill ?? .defaultNodeShading - ) - if let strokeEffect = op.stroke { - switch strokeEffect.color { - case .color(let color): - graphicsContext.stroke( - path, - with: .color(color), - style: strokeEffect.style ?? .defaultLinkStyle - ) - case .clip: - graphicsContext.blendMode = .clear - graphicsContext.stroke( - path, - with: .color(.black), - style: strokeEffect.style ?? .defaultLinkStyle + + graphicsContext.transform = .init(translationX: pos.x, y: pos.y) + + let finalizedPath: Path = + switch op.pathOrSymbolSize { + case .path(let path): path + case .symbolSize(let size): + Path( + ellipseIn: CGRect( + origin: CGPoint(x: -size.width / 2, y: -size.height / 2), + size: size ) - graphicsContext.blendMode = .normal - } - } - } else { - graphicsContext.transform = .identity - let rect = CGRect( - origin: (pos - op.mark.radius).cgPoint, - size: CGSize( - width: op.mark.radius * 2, height: op.mark.radius * 2 ) - ) - graphicsContext.fill( - Path(ellipseIn: rect), - with: op.fill ?? .defaultNodeShading - ) + } - if let strokeEffect = op.stroke { - switch strokeEffect.color { - case .color(let color): - graphicsContext.stroke( - Path(ellipseIn: rect), - with: .color(color), - style: strokeEffect.style ?? .defaultLinkStyle - ) - case .clip: - graphicsContext.blendMode = .clear - graphicsContext.stroke( - Path(ellipseIn: rect), - with: .color(.black), - style: strokeEffect.style ?? .defaultLinkStyle - ) - graphicsContext.blendMode = .normal - } + graphicsContext.fill( + finalizedPath, + with: op.fill ?? .defaultNodeShading + ) + if let strokeEffect = op.stroke { + switch strokeEffect.color { + case .color(let color): + graphicsContext.stroke( + finalizedPath, + with: .color(color), + style: strokeEffect.style ?? .defaultLinkStyle + ) + case .clip: + graphicsContext.blendMode = .clear + graphicsContext.stroke( + finalizedPath, + with: .color(.black), + style: strokeEffect.style ?? .defaultLinkStyle + ) + graphicsContext.blendMode = .normal } } } diff --git a/Sources/Grape/Views/GraphRenderingStates.swift b/Sources/Grape/Views/GraphRenderingStates.swift index 82f5bf2..0323c83 100644 --- a/Sources/Grape/Views/GraphRenderingStates.swift +++ b/Sources/Grape/Views/GraphRenderingStates.swift @@ -34,7 +34,13 @@ internal struct GraphRenderingStates { var symbolShape: [Path] = [] @inlinable - var currentSymbolShape: Path? { symbolShape.last } + var currentSymbolShapeOrSize: PathOrSymbolSize { + if let shape = symbolShape.last { + return .path(shape) + } else { + return .symbolSize(currentSymbolSizeOrDefault) + } + } @usableFromInline var symbolSize: [CGSize] = [] diff --git a/Sources/Grape/Views/RenderOperation.swift b/Sources/Grape/Views/RenderOperation.swift index 383a973..b5fc624 100644 --- a/Sources/Grape/Views/RenderOperation.swift +++ b/Sources/Grape/Views/RenderOperation.swift @@ -1,7 +1,14 @@ import SwiftUI +@usableFromInline +enum PathOrSymbolSize { + case path(Path) + case symbolSize(CGSize) +} + @usableFromInline internal enum RenderOperation { + @usableFromInline struct Node { @usableFromInline @@ -11,19 +18,19 @@ internal enum RenderOperation { @usableFromInline let stroke: GraphContentEffect.Stroke? @usableFromInline - let path: Path? + let pathOrSymbolSize: PathOrSymbolSize @inlinable init( _ mark: NodeMark, _ fill: GraphicsContext.Shading?, _ stroke: GraphContentEffect.Stroke?, - _ path: Path? + _ pathOrSymbolSize: PathOrSymbolSize ) { self.mark = mark self.fill = fill self.stroke = stroke - self.path = path + self.pathOrSymbolSize = pathOrSymbolSize } } @@ -53,7 +60,7 @@ extension RenderOperation.Node: Equatable { @inlinable internal static func == (lhs: Self, rhs: Self) -> Bool { let fillEq = lhs.fill == nil && rhs.fill == nil - let pathEq = lhs.path == nil && rhs.path == nil + let pathEq = lhs.pathOrSymbolSize == nil && rhs.pathOrSymbolSize == nil return lhs.mark == rhs.mark && fillEq && lhs.stroke == rhs.stroke From d775fe6a6d1fe8cf9e5735d098c0edb84243705c Mon Sep 17 00:00:00 2001 From: Zhen Li Date: Sat, 28 Dec 2024 16:11:14 -0800 Subject: [PATCH 3/3] Rename nodeRadiusSquaredLookup to nodeHitSizeAreaLookup --- .../ForceDirectedGraphExample/MyRing.swift | 2 +- Sources/Grape/Contents/NodeMark.swift | 2 +- Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift | 2 +- Sources/Grape/Views/ForceDirectedGraphModel.swift | 4 ++-- Sources/Grape/Views/GraphRenderingContext.swift | 3 ++- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift index 4b46f28..30aa22d 100644 --- a/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift +++ b/Examples/ForceDirectedGraphExample/ForceDirectedGraphExample/MyRing.swift @@ -24,7 +24,7 @@ struct MyRing: View { .stroke(.clear) NodeMark(id: 3 * i + 1) .symbol(.pentagon) - .symbolSize(radius:5.0) + .symbolSize(radius:10) .foregroundStyle(.blue) .stroke(.clear) NodeMark(id: 3 * i + 2) diff --git a/Sources/Grape/Contents/NodeMark.swift b/Sources/Grape/Contents/NodeMark.swift index d8644d6..588cb5e 100644 --- a/Sources/Grape/Contents/NodeMark.swift +++ b/Sources/Grape/Contents/NodeMark.swift @@ -23,7 +23,7 @@ public struct NodeMark: GraphContent, Identifiable, Equatable ) ) context.states.currentID = .node(id) - context.nodeRadiusSquaredLookup[id] = simd_length_squared( + context.nodeHitSizeAreaLookup[id] = simd_length_squared( context.states.currentSymbolSizeOrDefault.simd) } } diff --git a/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift b/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift index 5f016ec..706a0b7 100644 --- a/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift +++ b/Sources/Grape/Views/ForceDirectedGraphModel.findNode.swift @@ -13,7 +13,7 @@ extension ForceDirectedGraphModel { for i in simulationContext.storage.kinetics.range.reversed() { let iNodeID = simulationContext.nodeIndices[i] guard - let iRadius2 = graphRenderingContext.nodeRadiusSquaredLookup[ + let iRadius2 = graphRenderingContext.nodeHitSizeAreaLookup[ simulationContext.nodeIndices[i] ] else { continue } diff --git a/Sources/Grape/Views/ForceDirectedGraphModel.swift b/Sources/Grape/Views/ForceDirectedGraphModel.swift index 2cbc920..7795c1e 100644 --- a/Sources/Grape/Views/ForceDirectedGraphModel.swift +++ b/Sources/Grape/Views/ForceDirectedGraphModel.swift @@ -361,9 +361,9 @@ extension ForceDirectedGraphModel { if let pathBuilder = op.path { { let sourceNodeRadius = - sqrt(graphRenderingContext.nodeRadiusSquaredLookup[op.mark.id.source] ?? 0) / 2 + sqrt(graphRenderingContext.nodeHitSizeAreaLookup[op.mark.id.source] ?? 0) / 2 let targetNodeRadius = - sqrt(graphRenderingContext.nodeRadiusSquaredLookup[op.mark.id.target] ?? 0) / 2 + sqrt(graphRenderingContext.nodeHitSizeAreaLookup[op.mark.id.target] ?? 0) / 2 let angle = atan2(targetPos.y - sourcePos.y, targetPos.x - sourcePos.x) let sourceOffset = SIMD2( cos(angle) * sourceNodeRadius, sin(angle) * sourceNodeRadius diff --git a/Sources/Grape/Views/GraphRenderingContext.swift b/Sources/Grape/Views/GraphRenderingContext.swift index ccfd6ed..604d0aa 100644 --- a/Sources/Grape/Views/GraphRenderingContext.swift +++ b/Sources/Grape/Views/GraphRenderingContext.swift @@ -24,8 +24,9 @@ public struct _GraphRenderingContext { @usableFromInline internal var nodeOperations: [RenderOperation.Node] = [] + /// A lookup table for the hit area of each node (width * height). @usableFromInline - internal var nodeRadiusSquaredLookup: [NodeID: Double] = [:] + internal var nodeHitSizeAreaLookup: [NodeID: Double] = [:] @usableFromInline internal var linkOperations: [RenderOperation.Link] = []