├── FDG.gif
├── .DS_Store
├── Shared
├── .DS_Store
├── Assets.xcassets
│ ├── Contents.json
│ ├── .DS_Store
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── ForceDirectedGraphApp.swift
├── GraphControl.swift
├── simple.json
├── ContentView.swift
├── Utility.swift
├── Graph.swift
├── GraphView.swift
├── GraphViewModel.swift
├── GraphLayout.swift
└── miserables.json
├── ForceDirectedGraph.xcodeproj
├── project.xcworkspace
│ ├── contents.xcworkspacedata
│ ├── xcuserdata
│ │ └── rayfix.xcuserdatad
│ │ │ └── UserInterfaceState.xcuserstate
│ └── xcshareddata
│ │ └── IDEWorkspaceChecks.plist
├── xcuserdata
│ └── rayfix.xcuserdatad
│ │ ├── xcschemes
│ │ └── xcschememanagement.plist
│ │ └── xcdebugger
│ │ └── Breakpoints_v2.xcbkptlist
└── project.pbxproj
└── README.md
/FDG.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rayfix/ForceDirectedGraph/HEAD/FDG.gif
--------------------------------------------------------------------------------
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rayfix/ForceDirectedGraph/HEAD/.DS_Store
--------------------------------------------------------------------------------
/Shared/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rayfix/ForceDirectedGraph/HEAD/Shared/.DS_Store
--------------------------------------------------------------------------------
/Shared/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Shared/Assets.xcassets/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rayfix/ForceDirectedGraph/HEAD/Shared/Assets.xcassets/.DS_Store
--------------------------------------------------------------------------------
/ForceDirectedGraph.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/ForceDirectedGraph.xcodeproj/project.xcworkspace/xcuserdata/rayfix.xcuserdatad/UserInterfaceState.xcuserstate:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rayfix/ForceDirectedGraph/HEAD/ForceDirectedGraph.xcodeproj/project.xcworkspace/xcuserdata/rayfix.xcuserdatad/UserInterfaceState.xcuserstate
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Force-directed Graph Layout
2 |
3 | This repo SwiftUI implementation of a forced directed graph simulation. I used it for
4 | [A Flock of Swifts](http://aflockofswifts.org/) presentation.
5 |
6 | Now using `Canvas` and `TimelineView`.
7 |
8 | 
9 |
--------------------------------------------------------------------------------
/Shared/ForceDirectedGraphApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ForceDirectedGraphApp.swift
3 | // Shared
4 | //
5 | // Created by Ray Fix on 11/26/21.
6 | //
7 |
8 | import SwiftUI
9 |
10 | @main
11 | struct ForceDirectedGraphApp: App {
12 |
13 | var body: some Scene {
14 | WindowGroup {
15 | ContentView()
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/ForceDirectedGraph.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/ForceDirectedGraph.xcodeproj/xcuserdata/rayfix.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | ForceDirectedGraph (iOS).xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 0
11 |
12 | ForceDirectedGraph (macOS).xcscheme_^#shared#^_
13 |
14 | orderHint
15 | 0
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/Shared/GraphControl.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GraphControl.swift
3 | //
4 | // Created by Ray Fix on 7/19/19.
5 | // Copyright © 2019-2021 Ray Fix. All rights reserved.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct GraphControl: View {
11 | @ObservedObject var viewModel: GraphViewModel
12 | var body: some View {
13 | VStack {
14 | GraphView(viewModel: viewModel)
15 | HStack {
16 | Picker("Type", selection: $viewModel.layout) {
17 | Text("Circular").tag(Layout.circular)
18 | Text("Force").tag(Layout.forceDirected)
19 | }.pickerStyle(.segmented)
20 | Toggle(isOn: $viewModel.showIDs) {
21 | Text("Names")
22 | }
23 | }.padding()
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/Shared/simple.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": [
3 | {"id": "A", "group": 1},
4 | {"id": "B", "group": 2},
5 | {"id": "C", "group": 2},
6 | {"id": "D", "group": 2},
7 | {"id": "E", "group": 2},
8 | {"id": "F", "group": 2},
9 | {"id": "G", "group": 2},
10 | {"id": "G1", "group": 3},
11 | {"id": "G2", "group": 3},
12 | {"id": "G3", "group": 3},
13 | {"id": "C1", "group": 3},
14 |
15 | ],
16 | "links": [
17 | {"source": "A", "target": "B", "value": 1},
18 | {"source": "A", "target": "C", "value": 1},
19 | {"source": "A", "target": "D", "value": 1},
20 | {"source": "A", "target": "E", "value": 1},
21 | {"source": "A", "target": "F", "value": 1},
22 | {"source": "A", "target": "G", "value": 1},
23 | {"source": "G", "target": "G1", "value": 1},
24 | {"source": "G", "target": "G2", "value": 1},
25 | {"source": "G", "target": "G3", "value": 1},
26 | {"source": "C", "target": "C1", "value": 1},
27 | ]
28 | }
29 |
30 |
--------------------------------------------------------------------------------
/Shared/ContentView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ContentView.swift
3 | // Shared
4 | //
5 | // Created by Ray Fix on 11/26/21.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct ContentView: View {
11 |
12 | @StateObject var simple: GraphViewModel = {
13 | let graph = try! Graph.load(filename: "simple",
14 | layout: CircularGraphLayout())
15 | return GraphViewModel(graph)
16 | }()
17 |
18 | @StateObject var miserables: GraphViewModel = {
19 | let graph = try! Graph.load(filename: "miserables",
20 | layout: CircularGraphLayout())
21 | return GraphViewModel(graph)
22 | }()
23 |
24 | var body: some View {
25 | NavigationView {
26 | List {
27 | NavigationLink("Simple", destination: GraphControl(viewModel: simple))
28 | NavigationLink("Miserables", destination: GraphControl(viewModel: miserables))
29 | }.navigationBarTitle("Graphs")
30 | }
31 | }
32 | }
33 |
34 | struct ContentView_Previews: PreviewProvider {
35 | static var previews: some View {
36 | ContentView()
37 | .previewInterfaceOrientation(.landscapeLeft)
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Shared/Utility.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Utility.swift
3 | //
4 | // Created by Ray Fix on 7/18/19.
5 | // Copyright © 2019-2021 Ray Fix. All rights reserved.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// A palette of colors for visualizations
11 | struct Palette {
12 | static func color(for index: Int) -> Color {
13 | return colors[index % colors.count]
14 | }
15 | private static let colors: [Color] = [.red, .green, .cyan, .orange, .yellow, .purple, .pink, .black]
16 | }
17 |
18 | extension CGPoint {
19 | @inlinable
20 | init(_ x: Double, _ y: Double) {
21 | self.init(x: x, y: y)
22 | }
23 |
24 | @inlinable
25 | static func +(lhs: CGPoint, rhs: CGPoint) -> CGPoint {
26 | return CGPoint(lhs.x+rhs.x, lhs.y+rhs.y)
27 | }
28 |
29 | @inlinable
30 | static prefix func -(point: CGPoint) -> CGPoint {
31 | return CGPoint(-point.x, -point.y)
32 | }
33 |
34 | @inlinable
35 | static func -(lhs: CGPoint, rhs: CGPoint) -> CGPoint {
36 | return lhs + (-rhs)
37 | }
38 |
39 | @inlinable
40 | static func +=(lhs: inout CGPoint, rhs: CGPoint) {
41 | lhs = lhs + rhs
42 | }
43 |
44 | @inlinable
45 | static func *(lhs: CGPoint, rhs: CGFloat) -> CGPoint {
46 | CGPoint(lhs.x*rhs, lhs.y*rhs)
47 | }
48 |
49 | @inlinable
50 | static func /(lhs: CGPoint, rhs: CGFloat) -> CGPoint {
51 | CGPoint(lhs.x/rhs, lhs.y/rhs)
52 | }
53 |
54 | @inlinable
55 | var distanceSquared: CGFloat { x*x + y*y }
56 |
57 | @inlinable
58 | var distance: CGFloat { distanceSquared.squareRoot() }
59 | }
60 |
61 | extension Collection where Element == CGPoint {
62 | func meanPoint() -> CGPoint? {
63 | guard count != 0 else { return nil }
64 | return reduce(.zero, +) / CGFloat(count)
65 | }
66 | }
67 |
68 |
--------------------------------------------------------------------------------
/Shared/Graph.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Graph.swift
3 | //
4 | // Created by Ray Fix on 7/18/19.
5 | // Copyright © 2019-2021 Ray Fix. All rights reserved.
6 | //
7 |
8 | import Foundation
9 | import CoreGraphics
10 |
11 | /// A node represents a vertex of the graph (a dot)
12 | struct Node: Codable, Identifiable {
13 | var id: String
14 | var group: Int
15 |
16 | // Normalized space
17 | var position: CGPoint
18 | var velocity: CGPoint
19 | var isInteractive: Bool
20 |
21 | enum CodingKeys: CodingKey {
22 | case id, group, position, velocity
23 | }
24 |
25 | init(from decoder: Decoder) throws {
26 | let container = try decoder.container(keyedBy: CodingKeys.self)
27 | self.id = try container.decode(String.self, forKey: .id)
28 | self.group = try container.decode(Int.self, forKey: .group)
29 | self.position = try container.decodeIfPresent(CGPoint.self, forKey: .position) ?? .zero
30 | self.velocity = try container.decodeIfPresent(CGPoint.self, forKey: .velocity) ?? .zero
31 | self.isInteractive = false
32 | }
33 | }
34 |
35 | /// A link is the edge between two nodes
36 | struct Link: Codable {
37 | var source: String
38 | var target: String
39 | var value: Int
40 | }
41 |
42 | /// A graph is a collection of nodes and links between the nodes
43 | struct Graph: Codable {
44 | var nodes: [Node]
45 | var links: [Link]
46 | }
47 |
48 | /// Loading extensions of Graph that are part of the fundamental abstraction
49 | extension Graph {
50 | enum Error: Swift.Error {
51 | case fileNotFound(String)
52 | }
53 |
54 | init(jsonData: Data) throws {
55 | self = try JSONDecoder().decode(Self.self, from: jsonData)
56 | }
57 |
58 | static func load(filename: String, layout: GraphLayout? = nil, bundle: Bundle = Bundle.main) throws -> Self {
59 | guard let url = bundle.url(forResource: filename,
60 | withExtension: "json") else {
61 | throw Error.fileNotFound(filename)
62 | }
63 | let data = try Data(contentsOf: url)
64 | var graph = try Self(jsonData: data)
65 |
66 | layout?.update(graph: &graph)
67 |
68 | return graph
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/Shared/GraphView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | struct GraphView: View {
4 |
5 | @ObservedObject var viewModel: GraphViewModel
6 |
7 | enum Constant {
8 | static let fontSize = 12.0
9 | }
10 |
11 | @State var isDragging = false
12 | @State var draggingIndex: Int?
13 | @State var previous: Date?
14 |
15 | var drag: some Gesture {
16 | let tap = DragGesture(minimumDistance: 0, coordinateSpace: .local)
17 | .onChanged { drag in
18 | if isDragging, let index = draggingIndex {
19 | viewModel.dragNode(at: index, location: drag.location)
20 | } else {
21 | draggingIndex = viewModel.hitTest(point: drag.location)
22 | }
23 | isDragging = true
24 | }
25 | .onEnded { _ in
26 | if let index = draggingIndex {
27 | viewModel.stopDraggingNode(at: index)
28 | }
29 | isDragging = false
30 | draggingIndex = nil
31 | }
32 | return tap
33 | }
34 |
35 | var body: some View {
36 | TimelineView(.animation) { timeline in
37 | Canvas { context, size in
38 | viewModel.canvasSize = size
39 | viewModel.updateSimulation()
40 |
41 | context.transform = viewModel.modelToView
42 |
43 | let links = Path { drawing in
44 | for link in viewModel.linkSegments() {
45 | drawing.move(to: link.0)
46 | drawing.addLine(to: link.1)
47 | }
48 | }
49 |
50 | context.stroke(links, with: .color(white: 0.9),
51 | lineWidth: viewModel.linkWidthModel)
52 |
53 | for node in viewModel.graph.nodes {
54 | let ellipse = viewModel.modelRect(node: node)
55 | context.fill(Path(ellipseIn: ellipse), with: .color(Palette.color(for: node.group)))
56 | }
57 |
58 | if viewModel.showIDs {
59 | context.transform = .identity
60 | let font = Font.system(size: Constant.fontSize, weight: .bold)
61 | for node in viewModel.graph.nodes {
62 | context.draw(Text(node.id).font(font),
63 | at: node.position.applying(viewModel.modelToView))
64 | }
65 | }
66 | }.gesture(drag) // Comment either this line or the line below out and it works.
67 | }
68 | }
69 | }
70 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/Shared/GraphViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GraphViewModel.swift
3 | // ForceDirectedGraph (iOS)
4 | //
5 | // Created by Ray Fix on 11/26/21.
6 | //
7 |
8 | import Combine
9 | import CoreGraphics.CGAffineTransform
10 |
11 | enum Layout: Int, Hashable {
12 | case circular, forceDirected
13 |
14 | func makeEngine() -> GraphLayout {
15 | switch self {
16 | case .circular:
17 | return CircularGraphLayout()
18 | case .forceDirected:
19 | return ForceDirectedGraphLayout()
20 | }
21 | }
22 | }
23 |
24 | final class GraphViewModel: ObservableObject {
25 |
26 | enum Constant {
27 | static let nodeSize = 20.0
28 | static let fontSize = 12.0
29 | static let linkWidth = 2.0
30 | }
31 |
32 | var graph: Graph
33 | @Published var showIDs = false
34 |
35 | var canvasSize: CGSize = .zero {
36 | didSet {
37 | let minDimension = min(canvasSize.width, canvasSize.height)
38 |
39 | modelToView = CGAffineTransform.identity
40 | .translatedBy(x: (canvasSize.width - minDimension) * 0.5,
41 | y: (canvasSize.height - minDimension) * 0.5)
42 | .scaledBy(x: minDimension, y: minDimension)
43 | viewToModel = modelToView.inverted()
44 | }
45 | }
46 |
47 | var layout = Layout.circular {
48 | didSet {
49 | layoutEngine = layout.makeEngine()
50 | }
51 | }
52 | private var layoutEngine: GraphLayout = CircularGraphLayout()
53 |
54 | init(_ graph: Graph) {
55 | self.graph = graph
56 | }
57 |
58 | private(set) var viewToModel: CGAffineTransform = .identity
59 | private(set) var modelToView: CGAffineTransform = .identity
60 |
61 | var linkWidthModel: Double {
62 | Constant.linkWidth * viewToModel.a
63 | }
64 |
65 | func modelRect(node: Node) -> CGRect {
66 | let inset = -Constant.nodeSize / (modelToView.a * 2)
67 | return CGRect(origin: node.position, size: .zero)
68 | .insetBy(dx: inset, dy: inset)
69 | }
70 |
71 | func hitTest(point: CGPoint) -> Int? {
72 | let modelPoint = point.applying(viewToModel)
73 | return graph.nodes.firstIndex { modelRect(node: $0).contains(modelPoint) }
74 | }
75 |
76 | func dragNode(at index: Int, location: CGPoint) {
77 | let point = location.applying(viewToModel)
78 | graph.nodes[index].position = point
79 | graph.nodes[index].velocity = .zero
80 | graph.nodes[index].isInteractive = true
81 | }
82 |
83 | func stopDraggingNode(at index: Int) {
84 | graph.nodes[index].isInteractive = false
85 | }
86 |
87 | func linkSegments() -> [(CGPoint, CGPoint)] {
88 | let lookup = Dictionary(uniqueKeysWithValues:
89 | graph.nodes.map { ($0.id, $0.position) })
90 | return graph.links.compactMap { link in
91 | guard let source = lookup[link.source],
92 | let target = lookup[link.target] else {
93 | return nil
94 | }
95 | return (source, target)
96 | }
97 | }
98 |
99 | func updateSimulation() {
100 | layoutEngine.update(graph: &graph)
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/Shared/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "scale" : "2x",
6 | "size" : "20x20"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "scale" : "3x",
11 | "size" : "20x20"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "scale" : "2x",
16 | "size" : "29x29"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "scale" : "3x",
21 | "size" : "29x29"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "scale" : "2x",
26 | "size" : "40x40"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "scale" : "3x",
31 | "size" : "40x40"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "scale" : "2x",
36 | "size" : "60x60"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "scale" : "3x",
41 | "size" : "60x60"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "scale" : "1x",
46 | "size" : "20x20"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "scale" : "2x",
51 | "size" : "20x20"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "scale" : "1x",
56 | "size" : "29x29"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "scale" : "2x",
61 | "size" : "29x29"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "scale" : "1x",
66 | "size" : "40x40"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "scale" : "2x",
71 | "size" : "40x40"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "scale" : "1x",
76 | "size" : "76x76"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "scale" : "2x",
81 | "size" : "76x76"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "scale" : "2x",
86 | "size" : "83.5x83.5"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "scale" : "1x",
91 | "size" : "1024x1024"
92 | },
93 | {
94 | "idiom" : "mac",
95 | "scale" : "1x",
96 | "size" : "16x16"
97 | },
98 | {
99 | "idiom" : "mac",
100 | "scale" : "2x",
101 | "size" : "16x16"
102 | },
103 | {
104 | "idiom" : "mac",
105 | "scale" : "1x",
106 | "size" : "32x32"
107 | },
108 | {
109 | "idiom" : "mac",
110 | "scale" : "2x",
111 | "size" : "32x32"
112 | },
113 | {
114 | "idiom" : "mac",
115 | "scale" : "1x",
116 | "size" : "128x128"
117 | },
118 | {
119 | "idiom" : "mac",
120 | "scale" : "2x",
121 | "size" : "128x128"
122 | },
123 | {
124 | "idiom" : "mac",
125 | "scale" : "1x",
126 | "size" : "256x256"
127 | },
128 | {
129 | "idiom" : "mac",
130 | "scale" : "2x",
131 | "size" : "256x256"
132 | },
133 | {
134 | "idiom" : "mac",
135 | "scale" : "1x",
136 | "size" : "512x512"
137 | },
138 | {
139 | "idiom" : "mac",
140 | "scale" : "2x",
141 | "size" : "512x512"
142 | }
143 | ],
144 | "info" : {
145 | "author" : "xcode",
146 | "version" : 1
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/Shared/GraphLayout.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ForceDirectedLayout.swift
3 | //
4 | // Created by Ray Fix on 7/19/19.
5 | // Copyright © 2019-2021 Ray Fix. All rights reserved.
6 | //
7 |
8 | import Foundation
9 | import CoreGraphics.CGAffineTransform
10 |
11 | /// A way to compute
12 | protocol GraphLayout {
13 | func update(graph: inout Graph)
14 | }
15 |
16 | final class CircularGraphLayout: GraphLayout {
17 | var startAngle = 0.0
18 |
19 | func update(graph: inout Graph) {
20 | let radius = 0.4
21 | let center = CGPoint(0.5, 0.5)
22 | let delta = 2 * CGFloat.pi / CGFloat(graph.nodes.count)
23 |
24 | var angle = startAngle
25 | for index in graph.nodes.indices {
26 | defer { angle += delta }
27 | guard !graph.nodes[index].isInteractive else { continue }
28 | graph.nodes[index].position = center +
29 | CGPoint(cos(Double(angle)),
30 | sin(Double(angle))) * radius
31 | graph.nodes[index].velocity = .zero
32 |
33 | }
34 | startAngle += 0.005
35 | }
36 | }
37 |
38 | /// Implementation of Force-directed Graph Layout
39 | struct ForceDirectedGraphLayout: GraphLayout {
40 |
41 | let friction = 0.001
42 | let springLength = 0.15
43 | let springConstant = 40.0
44 | let chargeConstant = 0.05875
45 |
46 | private func computeSpringForces(source: CGPoint, targets: [CGPoint]) -> CGPoint {
47 | var accum = CGPoint.zero
48 |
49 | for target in targets {
50 | let delta = target - source
51 | let length = delta.distance
52 | guard length > 0 else { continue }
53 | let unit = delta / length
54 | accum += unit * (length-springLength) * springConstant
55 | }
56 |
57 | return accum
58 | }
59 |
60 | private func computeRepulsion(at reference: CGPoint, from others: [CGPoint], skipIndex: Int) -> CGPoint {
61 |
62 | var accum = CGPoint.zero
63 |
64 | for (offset, other) in others.enumerated() {
65 | guard offset != skipIndex else { continue }
66 | let diff = reference - other
67 | guard diff.distanceSquared > 1e-8 else { continue }
68 | accum += diff / diff.distanceSquared * chargeConstant
69 | }
70 | return accum
71 | }
72 |
73 | func update(graph: inout Graph) {
74 |
75 | var positions = graph.nodes.map { $0.position }
76 | var velocities = graph.nodes.map { $0.velocity }
77 |
78 | let lookup = Dictionary(uniqueKeysWithValues:
79 | graph.nodes.enumerated().map { ($0.element.id, $0.offset) })
80 | var targets: [[CGPoint]] = Array(repeating: [], count: positions.count)
81 |
82 | for link in graph.links {
83 | guard let source = lookup[link.source],
84 | let target = lookup[link.target] else { continue }
85 | targets[source].append(positions[target])
86 | targets[target].append(positions[source])
87 | }
88 |
89 | var forces = Array(repeating: CGPoint.zero, count: positions.count)
90 |
91 | for (offset, position) in positions.enumerated() {
92 | forces[offset] += computeRepulsion(at: position, from: positions, skipIndex: offset)
93 | forces[offset] += computeSpringForces(source: position, targets: targets[offset])
94 | }
95 |
96 | // Centering force
97 | let centering = CGPoint(0.5, 0.5) - (positions.meanPoint() ?? .zero)
98 |
99 | // integrate the forces to get velocities
100 | for (index, velocity) in velocities.enumerated() {
101 | let new = velocity + forces[index]
102 | velocities[index] = new * friction
103 | }
104 |
105 | // integrate the velocities to get positions
106 | for index in positions.indices {
107 | if graph.nodes[index].isInteractive {
108 | velocities[index] = .zero
109 | continue
110 | }
111 | positions[index] += velocities[index] + centering
112 | }
113 |
114 | // Copy them in
115 | for index in positions.indices {
116 | graph.nodes[index].position = positions[index]
117 | graph.nodes[index].velocity = velocities[index]
118 | }
119 | }
120 | }
121 |
122 |
--------------------------------------------------------------------------------
/ForceDirectedGraph.xcodeproj/xcuserdata/rayfix.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
9 |
21 |
22 |
36 |
37 |
51 |
52 |
66 |
67 |
81 |
82 |
96 |
97 |
98 |
99 |
100 |
102 |
114 |
115 |
116 |
117 |
118 |
--------------------------------------------------------------------------------
/ForceDirectedGraph.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 55;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 5D8BA892275158F500BB843F /* ForceDirectedGraphApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8BA882275158F400BB843F /* ForceDirectedGraphApp.swift */; };
11 | 5D8BA894275158F500BB843F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8BA883275158F400BB843F /* ContentView.swift */; };
12 | 5D8BA896275158F500BB843F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5D8BA884275158F500BB843F /* Assets.xcassets */; };
13 | 5D8BA8A12751593500BB843F /* Graph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8BA8A02751593500BB843F /* Graph.swift */; };
14 | 5D8BA8A42751593F00BB843F /* GraphLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8BA8A32751593F00BB843F /* GraphLayout.swift */; };
15 | 5D8BA8A72751594A00BB843F /* GraphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8BA8A62751594A00BB843F /* GraphView.swift */; };
16 | 5D8BA8AD2751596E00BB843F /* GraphControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8BA8AC2751596E00BB843F /* GraphControl.swift */; };
17 | 5D8BA8B02751597700BB843F /* miserables.json in Resources */ = {isa = PBXBuildFile; fileRef = 5D8BA8AF2751597700BB843F /* miserables.json */; };
18 | 5D8BA8B62751598F00BB843F /* simple.json in Resources */ = {isa = PBXBuildFile; fileRef = 5D8BA8B52751598F00BB843F /* simple.json */; };
19 | 5D8BA8B92751599700BB843F /* Utility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8BA8B82751599700BB843F /* Utility.swift */; };
20 | 5D8BA8BC2751DC0600BB843F /* GraphViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D8BA8BB2751DC0600BB843F /* GraphViewModel.swift */; };
21 | /* End PBXBuildFile section */
22 |
23 | /* Begin PBXFileReference section */
24 | 5D8BA882275158F400BB843F /* ForceDirectedGraphApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForceDirectedGraphApp.swift; sourceTree = ""; };
25 | 5D8BA883275158F400BB843F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
26 | 5D8BA884275158F500BB843F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
27 | 5D8BA889275158F500BB843F /* ForceDirectedGraph.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ForceDirectedGraph.app; sourceTree = BUILT_PRODUCTS_DIR; };
28 | 5D8BA8A02751593500BB843F /* Graph.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Graph.swift; sourceTree = ""; };
29 | 5D8BA8A32751593F00BB843F /* GraphLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphLayout.swift; sourceTree = ""; };
30 | 5D8BA8A62751594A00BB843F /* GraphView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphView.swift; sourceTree = ""; };
31 | 5D8BA8AC2751596E00BB843F /* GraphControl.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GraphControl.swift; sourceTree = ""; };
32 | 5D8BA8AF2751597700BB843F /* miserables.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = miserables.json; sourceTree = ""; };
33 | 5D8BA8B52751598F00BB843F /* simple.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = simple.json; sourceTree = ""; };
34 | 5D8BA8B82751599700BB843F /* Utility.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Utility.swift; sourceTree = ""; };
35 | 5D8BA8BB2751DC0600BB843F /* GraphViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GraphViewModel.swift; sourceTree = ""; };
36 | /* End PBXFileReference section */
37 |
38 | /* Begin PBXFrameworksBuildPhase section */
39 | 5D8BA886275158F500BB843F /* Frameworks */ = {
40 | isa = PBXFrameworksBuildPhase;
41 | buildActionMask = 2147483647;
42 | files = (
43 | );
44 | runOnlyForDeploymentPostprocessing = 0;
45 | };
46 | /* End PBXFrameworksBuildPhase section */
47 |
48 | /* Begin PBXGroup section */
49 | 5D8BA87C275158F400BB843F = {
50 | isa = PBXGroup;
51 | children = (
52 | 5D8BA881275158F400BB843F /* Shared */,
53 | 5D8BA88A275158F500BB843F /* Products */,
54 | );
55 | sourceTree = "";
56 | };
57 | 5D8BA881275158F400BB843F /* Shared */ = {
58 | isa = PBXGroup;
59 | children = (
60 | 5D8BA882275158F400BB843F /* ForceDirectedGraphApp.swift */,
61 | 5D8BA883275158F400BB843F /* ContentView.swift */,
62 | 5D8BA8A02751593500BB843F /* Graph.swift */,
63 | 5D8BA8A32751593F00BB843F /* GraphLayout.swift */,
64 | 5D8BA8A62751594A00BB843F /* GraphView.swift */,
65 | 5D8BA8BB2751DC0600BB843F /* GraphViewModel.swift */,
66 | 5D8BA8AC2751596E00BB843F /* GraphControl.swift */,
67 | 5D8BA8B82751599700BB843F /* Utility.swift */,
68 | 5D8BA8B52751598F00BB843F /* simple.json */,
69 | 5D8BA8AF2751597700BB843F /* miserables.json */,
70 | 5D8BA884275158F500BB843F /* Assets.xcassets */,
71 | );
72 | path = Shared;
73 | sourceTree = "";
74 | };
75 | 5D8BA88A275158F500BB843F /* Products */ = {
76 | isa = PBXGroup;
77 | children = (
78 | 5D8BA889275158F500BB843F /* ForceDirectedGraph.app */,
79 | );
80 | name = Products;
81 | sourceTree = "";
82 | };
83 | /* End PBXGroup section */
84 |
85 | /* Begin PBXNativeTarget section */
86 | 5D8BA888275158F500BB843F /* ForceDirectedGraph (iOS) */ = {
87 | isa = PBXNativeTarget;
88 | buildConfigurationList = 5D8BA89A275158F500BB843F /* Build configuration list for PBXNativeTarget "ForceDirectedGraph (iOS)" */;
89 | buildPhases = (
90 | 5D8BA885275158F500BB843F /* Sources */,
91 | 5D8BA886275158F500BB843F /* Frameworks */,
92 | 5D8BA887275158F500BB843F /* Resources */,
93 | );
94 | buildRules = (
95 | );
96 | dependencies = (
97 | );
98 | name = "ForceDirectedGraph (iOS)";
99 | productName = "ForceDirectedGraph (iOS)";
100 | productReference = 5D8BA889275158F500BB843F /* ForceDirectedGraph.app */;
101 | productType = "com.apple.product-type.application";
102 | };
103 | /* End PBXNativeTarget section */
104 |
105 | /* Begin PBXProject section */
106 | 5D8BA87D275158F400BB843F /* Project object */ = {
107 | isa = PBXProject;
108 | attributes = {
109 | BuildIndependentTargetsInParallel = 1;
110 | LastSwiftUpdateCheck = 1310;
111 | LastUpgradeCheck = 1310;
112 | TargetAttributes = {
113 | 5D8BA888275158F500BB843F = {
114 | CreatedOnToolsVersion = 13.1;
115 | };
116 | };
117 | };
118 | buildConfigurationList = 5D8BA880275158F400BB843F /* Build configuration list for PBXProject "ForceDirectedGraph" */;
119 | compatibilityVersion = "Xcode 13.0";
120 | developmentRegion = en;
121 | hasScannedForEncodings = 0;
122 | knownRegions = (
123 | en,
124 | Base,
125 | );
126 | mainGroup = 5D8BA87C275158F400BB843F;
127 | productRefGroup = 5D8BA88A275158F500BB843F /* Products */;
128 | projectDirPath = "";
129 | projectRoot = "";
130 | targets = (
131 | 5D8BA888275158F500BB843F /* ForceDirectedGraph (iOS) */,
132 | );
133 | };
134 | /* End PBXProject section */
135 |
136 | /* Begin PBXResourcesBuildPhase section */
137 | 5D8BA887275158F500BB843F /* Resources */ = {
138 | isa = PBXResourcesBuildPhase;
139 | buildActionMask = 2147483647;
140 | files = (
141 | 5D8BA8B02751597700BB843F /* miserables.json in Resources */,
142 | 5D8BA896275158F500BB843F /* Assets.xcassets in Resources */,
143 | 5D8BA8B62751598F00BB843F /* simple.json in Resources */,
144 | );
145 | runOnlyForDeploymentPostprocessing = 0;
146 | };
147 | /* End PBXResourcesBuildPhase section */
148 |
149 | /* Begin PBXSourcesBuildPhase section */
150 | 5D8BA885275158F500BB843F /* Sources */ = {
151 | isa = PBXSourcesBuildPhase;
152 | buildActionMask = 2147483647;
153 | files = (
154 | 5D8BA8A72751594A00BB843F /* GraphView.swift in Sources */,
155 | 5D8BA8A42751593F00BB843F /* GraphLayout.swift in Sources */,
156 | 5D8BA8B92751599700BB843F /* Utility.swift in Sources */,
157 | 5D8BA8AD2751596E00BB843F /* GraphControl.swift in Sources */,
158 | 5D8BA8BC2751DC0600BB843F /* GraphViewModel.swift in Sources */,
159 | 5D8BA8A12751593500BB843F /* Graph.swift in Sources */,
160 | 5D8BA894275158F500BB843F /* ContentView.swift in Sources */,
161 | 5D8BA892275158F500BB843F /* ForceDirectedGraphApp.swift in Sources */,
162 | );
163 | runOnlyForDeploymentPostprocessing = 0;
164 | };
165 | /* End PBXSourcesBuildPhase section */
166 |
167 | /* Begin XCBuildConfiguration section */
168 | 5D8BA898275158F500BB843F /* Debug */ = {
169 | isa = XCBuildConfiguration;
170 | buildSettings = {
171 | ALWAYS_SEARCH_USER_PATHS = NO;
172 | CLANG_ANALYZER_NONNULL = YES;
173 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
174 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
175 | CLANG_CXX_LIBRARY = "libc++";
176 | CLANG_ENABLE_MODULES = YES;
177 | CLANG_ENABLE_OBJC_ARC = YES;
178 | CLANG_ENABLE_OBJC_WEAK = YES;
179 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
180 | CLANG_WARN_BOOL_CONVERSION = YES;
181 | CLANG_WARN_COMMA = YES;
182 | CLANG_WARN_CONSTANT_CONVERSION = YES;
183 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
184 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
185 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
186 | CLANG_WARN_EMPTY_BODY = YES;
187 | CLANG_WARN_ENUM_CONVERSION = YES;
188 | CLANG_WARN_INFINITE_RECURSION = YES;
189 | CLANG_WARN_INT_CONVERSION = YES;
190 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
191 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
192 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
193 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
194 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
195 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
196 | CLANG_WARN_STRICT_PROTOTYPES = YES;
197 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
198 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
199 | CLANG_WARN_UNREACHABLE_CODE = YES;
200 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
201 | COPY_PHASE_STRIP = NO;
202 | DEBUG_INFORMATION_FORMAT = dwarf;
203 | ENABLE_STRICT_OBJC_MSGSEND = YES;
204 | ENABLE_TESTABILITY = YES;
205 | GCC_C_LANGUAGE_STANDARD = gnu11;
206 | GCC_DYNAMIC_NO_PIC = NO;
207 | GCC_NO_COMMON_BLOCKS = YES;
208 | GCC_OPTIMIZATION_LEVEL = 0;
209 | GCC_PREPROCESSOR_DEFINITIONS = (
210 | "DEBUG=1",
211 | "$(inherited)",
212 | );
213 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
214 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
215 | GCC_WARN_UNDECLARED_SELECTOR = YES;
216 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
217 | GCC_WARN_UNUSED_FUNCTION = YES;
218 | GCC_WARN_UNUSED_VARIABLE = YES;
219 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
220 | MTL_FAST_MATH = YES;
221 | ONLY_ACTIVE_ARCH = YES;
222 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
223 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
224 | };
225 | name = Debug;
226 | };
227 | 5D8BA899275158F500BB843F /* Release */ = {
228 | isa = XCBuildConfiguration;
229 | buildSettings = {
230 | ALWAYS_SEARCH_USER_PATHS = NO;
231 | CLANG_ANALYZER_NONNULL = YES;
232 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
233 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
234 | CLANG_CXX_LIBRARY = "libc++";
235 | CLANG_ENABLE_MODULES = YES;
236 | CLANG_ENABLE_OBJC_ARC = YES;
237 | CLANG_ENABLE_OBJC_WEAK = YES;
238 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
239 | CLANG_WARN_BOOL_CONVERSION = YES;
240 | CLANG_WARN_COMMA = YES;
241 | CLANG_WARN_CONSTANT_CONVERSION = YES;
242 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
243 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
244 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
245 | CLANG_WARN_EMPTY_BODY = YES;
246 | CLANG_WARN_ENUM_CONVERSION = YES;
247 | CLANG_WARN_INFINITE_RECURSION = YES;
248 | CLANG_WARN_INT_CONVERSION = YES;
249 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
250 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
251 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
252 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
253 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
254 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
255 | CLANG_WARN_STRICT_PROTOTYPES = YES;
256 | CLANG_WARN_SUSPICIOUS_MOVE = YES;
257 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
258 | CLANG_WARN_UNREACHABLE_CODE = YES;
259 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
260 | COPY_PHASE_STRIP = NO;
261 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
262 | ENABLE_NS_ASSERTIONS = NO;
263 | ENABLE_STRICT_OBJC_MSGSEND = YES;
264 | GCC_C_LANGUAGE_STANDARD = gnu11;
265 | GCC_NO_COMMON_BLOCKS = YES;
266 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
267 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
268 | GCC_WARN_UNDECLARED_SELECTOR = YES;
269 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
270 | GCC_WARN_UNUSED_FUNCTION = YES;
271 | GCC_WARN_UNUSED_VARIABLE = YES;
272 | MTL_ENABLE_DEBUG_INFO = NO;
273 | MTL_FAST_MATH = YES;
274 | SWIFT_COMPILATION_MODE = wholemodule;
275 | SWIFT_OPTIMIZATION_LEVEL = "-O";
276 | };
277 | name = Release;
278 | };
279 | 5D8BA89B275158F500BB843F /* Debug */ = {
280 | isa = XCBuildConfiguration;
281 | buildSettings = {
282 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
283 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
284 | CODE_SIGN_STYLE = Automatic;
285 | CURRENT_PROJECT_VERSION = 1;
286 | ENABLE_PREVIEWS = YES;
287 | GENERATE_INFOPLIST_FILE = YES;
288 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
289 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
290 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
291 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
292 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
293 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
294 | LD_RUNPATH_SEARCH_PATHS = (
295 | "$(inherited)",
296 | "@executable_path/Frameworks",
297 | );
298 | MARKETING_VERSION = 1.0;
299 | PRODUCT_BUNDLE_IDENTIFIER = org.rayfix.ForceDirectedGraph;
300 | PRODUCT_NAME = ForceDirectedGraph;
301 | SDKROOT = iphoneos;
302 | SWIFT_EMIT_LOC_STRINGS = YES;
303 | SWIFT_VERSION = 5.0;
304 | TARGETED_DEVICE_FAMILY = "1,2";
305 | };
306 | name = Debug;
307 | };
308 | 5D8BA89C275158F500BB843F /* Release */ = {
309 | isa = XCBuildConfiguration;
310 | buildSettings = {
311 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
312 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
313 | CODE_SIGN_STYLE = Automatic;
314 | CURRENT_PROJECT_VERSION = 1;
315 | ENABLE_PREVIEWS = YES;
316 | GENERATE_INFOPLIST_FILE = YES;
317 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
318 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
319 | INFOPLIST_KEY_UILaunchScreen_Generation = YES;
320 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
321 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
322 | IPHONEOS_DEPLOYMENT_TARGET = 15.0;
323 | LD_RUNPATH_SEARCH_PATHS = (
324 | "$(inherited)",
325 | "@executable_path/Frameworks",
326 | );
327 | MARKETING_VERSION = 1.0;
328 | PRODUCT_BUNDLE_IDENTIFIER = org.rayfix.ForceDirectedGraph;
329 | PRODUCT_NAME = ForceDirectedGraph;
330 | SDKROOT = iphoneos;
331 | SWIFT_EMIT_LOC_STRINGS = YES;
332 | SWIFT_VERSION = 5.0;
333 | TARGETED_DEVICE_FAMILY = "1,2";
334 | VALIDATE_PRODUCT = YES;
335 | };
336 | name = Release;
337 | };
338 | /* End XCBuildConfiguration section */
339 |
340 | /* Begin XCConfigurationList section */
341 | 5D8BA880275158F400BB843F /* Build configuration list for PBXProject "ForceDirectedGraph" */ = {
342 | isa = XCConfigurationList;
343 | buildConfigurations = (
344 | 5D8BA898275158F500BB843F /* Debug */,
345 | 5D8BA899275158F500BB843F /* Release */,
346 | );
347 | defaultConfigurationIsVisible = 0;
348 | defaultConfigurationName = Release;
349 | };
350 | 5D8BA89A275158F500BB843F /* Build configuration list for PBXNativeTarget "ForceDirectedGraph (iOS)" */ = {
351 | isa = XCConfigurationList;
352 | buildConfigurations = (
353 | 5D8BA89B275158F500BB843F /* Debug */,
354 | 5D8BA89C275158F500BB843F /* Release */,
355 | );
356 | defaultConfigurationIsVisible = 0;
357 | defaultConfigurationName = Release;
358 | };
359 | /* End XCConfigurationList section */
360 | };
361 | rootObject = 5D8BA87D275158F400BB843F /* Project object */;
362 | }
363 |
--------------------------------------------------------------------------------
/Shared/miserables.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": [
3 | {"id": "Myriel", "group": 1},
4 | {"id": "Napoleon", "group": 1},
5 | {"id": "Mlle.Baptistine", "group": 1},
6 | {"id": "Mme.Magloire", "group": 1},
7 | {"id": "CountessdeLo", "group": 1},
8 | {"id": "Geborand", "group": 1},
9 | {"id": "Champtercier", "group": 1},
10 | {"id": "Cravatte", "group": 1},
11 | {"id": "Count", "group": 1},
12 | {"id": "OldMan", "group": 1},
13 | {"id": "Labarre", "group": 2},
14 | {"id": "Valjean", "group": 2},
15 | {"id": "Marguerite", "group": 3},
16 | {"id": "Mme.deR", "group": 2},
17 | {"id": "Isabeau", "group": 2},
18 | {"id": "Gervais", "group": 2},
19 | {"id": "Tholomyes", "group": 3},
20 | {"id": "Listolier", "group": 3},
21 | {"id": "Fameuil", "group": 3},
22 | {"id": "Blacheville", "group": 3},
23 | {"id": "Favourite", "group": 3},
24 | {"id": "Dahlia", "group": 3},
25 | {"id": "Zephine", "group": 3},
26 | {"id": "Fantine", "group": 3},
27 | {"id": "Mme.Thenardier", "group": 4},
28 | {"id": "Thenardier", "group": 4},
29 | {"id": "Cosette", "group": 5},
30 | {"id": "Javert", "group": 4},
31 | {"id": "Fauchelevent", "group": 0},
32 | {"id": "Bamatabois", "group": 2},
33 | {"id": "Perpetue", "group": 3},
34 | {"id": "Simplice", "group": 2},
35 | {"id": "Scaufflaire", "group": 2},
36 | {"id": "Woman1", "group": 2},
37 | {"id": "Judge", "group": 2},
38 | {"id": "Champmathieu", "group": 2},
39 | {"id": "Brevet", "group": 2},
40 | {"id": "Chenildieu", "group": 2},
41 | {"id": "Cochepaille", "group": 2},
42 | {"id": "Pontmercy", "group": 4},
43 | {"id": "Boulatruelle", "group": 6},
44 | {"id": "Eponine", "group": 4},
45 | {"id": "Anzelma", "group": 4},
46 | {"id": "Woman2", "group": 5},
47 | {"id": "MotherInnocent", "group": 0},
48 | {"id": "Gribier", "group": 0},
49 | {"id": "Jondrette", "group": 7},
50 | {"id": "Mme.Burgon", "group": 7},
51 | {"id": "Gavroche", "group": 8},
52 | {"id": "Gillenormand", "group": 5},
53 | {"id": "Magnon", "group": 5},
54 | {"id": "Mlle.Gillenormand", "group": 5},
55 | {"id": "Mme.Pontmercy", "group": 5},
56 | {"id": "Mlle.Vaubois", "group": 5},
57 | {"id": "Lt.Gillenormand", "group": 5},
58 | {"id": "Marius", "group": 8},
59 | {"id": "BaronessT", "group": 5},
60 | {"id": "Mabeuf", "group": 8},
61 | {"id": "Enjolras", "group": 8},
62 | {"id": "Combeferre", "group": 8},
63 | {"id": "Prouvaire", "group": 8},
64 | {"id": "Feuilly", "group": 8},
65 | {"id": "Courfeyrac", "group": 8},
66 | {"id": "Bahorel", "group": 8},
67 | {"id": "Bossuet", "group": 8},
68 | {"id": "Joly", "group": 8},
69 | {"id": "Grantaire", "group": 8},
70 | {"id": "MotherPlutarch", "group": 9},
71 | {"id": "Gueulemer", "group": 4},
72 | {"id": "Babet", "group": 4},
73 | {"id": "Claquesous", "group": 4},
74 | {"id": "Montparnasse", "group": 4},
75 | {"id": "Toussaint", "group": 5},
76 | {"id": "Child1", "group": 10},
77 | {"id": "Child2", "group": 10},
78 | {"id": "Brujon", "group": 4},
79 | {"id": "Mme.Hucheloup", "group": 8}
80 | ],
81 | "links": [
82 | {"source": "Napoleon", "target": "Myriel", "value": 1},
83 | {"source": "Mlle.Baptistine", "target": "Myriel", "value": 8},
84 | {"source": "Mme.Magloire", "target": "Myriel", "value": 10},
85 | {"source": "Mme.Magloire", "target": "Mlle.Baptistine", "value": 6},
86 | {"source": "CountessdeLo", "target": "Myriel", "value": 1},
87 | {"source": "Geborand", "target": "Myriel", "value": 1},
88 | {"source": "Champtercier", "target": "Myriel", "value": 1},
89 | {"source": "Cravatte", "target": "Myriel", "value": 1},
90 | {"source": "Count", "target": "Myriel", "value": 2},
91 | {"source": "OldMan", "target": "Myriel", "value": 1},
92 | {"source": "Valjean", "target": "Labarre", "value": 1},
93 | {"source": "Valjean", "target": "Mme.Magloire", "value": 3},
94 | {"source": "Valjean", "target": "Mlle.Baptistine", "value": 3},
95 | {"source": "Valjean", "target": "Myriel", "value": 5},
96 | {"source": "Marguerite", "target": "Valjean", "value": 1},
97 | {"source": "Mme.deR", "target": "Valjean", "value": 1},
98 | {"source": "Isabeau", "target": "Valjean", "value": 1},
99 | {"source": "Gervais", "target": "Valjean", "value": 1},
100 | {"source": "Listolier", "target": "Tholomyes", "value": 4},
101 | {"source": "Fameuil", "target": "Tholomyes", "value": 4},
102 | {"source": "Fameuil", "target": "Listolier", "value": 4},
103 | {"source": "Blacheville", "target": "Tholomyes", "value": 4},
104 | {"source": "Blacheville", "target": "Listolier", "value": 4},
105 | {"source": "Blacheville", "target": "Fameuil", "value": 4},
106 | {"source": "Favourite", "target": "Tholomyes", "value": 3},
107 | {"source": "Favourite", "target": "Listolier", "value": 3},
108 | {"source": "Favourite", "target": "Fameuil", "value": 3},
109 | {"source": "Favourite", "target": "Blacheville", "value": 4},
110 | {"source": "Dahlia", "target": "Tholomyes", "value": 3},
111 | {"source": "Dahlia", "target": "Listolier", "value": 3},
112 | {"source": "Dahlia", "target": "Fameuil", "value": 3},
113 | {"source": "Dahlia", "target": "Blacheville", "value": 3},
114 | {"source": "Dahlia", "target": "Favourite", "value": 5},
115 | {"source": "Zephine", "target": "Tholomyes", "value": 3},
116 | {"source": "Zephine", "target": "Listolier", "value": 3},
117 | {"source": "Zephine", "target": "Fameuil", "value": 3},
118 | {"source": "Zephine", "target": "Blacheville", "value": 3},
119 | {"source": "Zephine", "target": "Favourite", "value": 4},
120 | {"source": "Zephine", "target": "Dahlia", "value": 4},
121 | {"source": "Fantine", "target": "Tholomyes", "value": 3},
122 | {"source": "Fantine", "target": "Listolier", "value": 3},
123 | {"source": "Fantine", "target": "Fameuil", "value": 3},
124 | {"source": "Fantine", "target": "Blacheville", "value": 3},
125 | {"source": "Fantine", "target": "Favourite", "value": 4},
126 | {"source": "Fantine", "target": "Dahlia", "value": 4},
127 | {"source": "Fantine", "target": "Zephine", "value": 4},
128 | {"source": "Fantine", "target": "Marguerite", "value": 2},
129 | {"source": "Fantine", "target": "Valjean", "value": 9},
130 | {"source": "Mme.Thenardier", "target": "Fantine", "value": 2},
131 | {"source": "Mme.Thenardier", "target": "Valjean", "value": 7},
132 | {"source": "Thenardier", "target": "Mme.Thenardier", "value": 13},
133 | {"source": "Thenardier", "target": "Fantine", "value": 1},
134 | {"source": "Thenardier", "target": "Valjean", "value": 12},
135 | {"source": "Cosette", "target": "Mme.Thenardier", "value": 4},
136 | {"source": "Cosette", "target": "Valjean", "value": 31},
137 | {"source": "Cosette", "target": "Tholomyes", "value": 1},
138 | {"source": "Cosette", "target": "Thenardier", "value": 1},
139 | {"source": "Javert", "target": "Valjean", "value": 17},
140 | {"source": "Javert", "target": "Fantine", "value": 5},
141 | {"source": "Javert", "target": "Thenardier", "value": 5},
142 | {"source": "Javert", "target": "Mme.Thenardier", "value": 1},
143 | {"source": "Javert", "target": "Cosette", "value": 1},
144 | {"source": "Fauchelevent", "target": "Valjean", "value": 8},
145 | {"source": "Fauchelevent", "target": "Javert", "value": 1},
146 | {"source": "Bamatabois", "target": "Fantine", "value": 1},
147 | {"source": "Bamatabois", "target": "Javert", "value": 1},
148 | {"source": "Bamatabois", "target": "Valjean", "value": 2},
149 | {"source": "Perpetue", "target": "Fantine", "value": 1},
150 | {"source": "Simplice", "target": "Perpetue", "value": 2},
151 | {"source": "Simplice", "target": "Valjean", "value": 3},
152 | {"source": "Simplice", "target": "Fantine", "value": 2},
153 | {"source": "Simplice", "target": "Javert", "value": 1},
154 | {"source": "Scaufflaire", "target": "Valjean", "value": 1},
155 | {"source": "Woman1", "target": "Valjean", "value": 2},
156 | {"source": "Woman1", "target": "Javert", "value": 1},
157 | {"source": "Judge", "target": "Valjean", "value": 3},
158 | {"source": "Judge", "target": "Bamatabois", "value": 2},
159 | {"source": "Champmathieu", "target": "Valjean", "value": 3},
160 | {"source": "Champmathieu", "target": "Judge", "value": 3},
161 | {"source": "Champmathieu", "target": "Bamatabois", "value": 2},
162 | {"source": "Brevet", "target": "Judge", "value": 2},
163 | {"source": "Brevet", "target": "Champmathieu", "value": 2},
164 | {"source": "Brevet", "target": "Valjean", "value": 2},
165 | {"source": "Brevet", "target": "Bamatabois", "value": 1},
166 | {"source": "Chenildieu", "target": "Judge", "value": 2},
167 | {"source": "Chenildieu", "target": "Champmathieu", "value": 2},
168 | {"source": "Chenildieu", "target": "Brevet", "value": 2},
169 | {"source": "Chenildieu", "target": "Valjean", "value": 2},
170 | {"source": "Chenildieu", "target": "Bamatabois", "value": 1},
171 | {"source": "Cochepaille", "target": "Judge", "value": 2},
172 | {"source": "Cochepaille", "target": "Champmathieu", "value": 2},
173 | {"source": "Cochepaille", "target": "Brevet", "value": 2},
174 | {"source": "Cochepaille", "target": "Chenildieu", "value": 2},
175 | {"source": "Cochepaille", "target": "Valjean", "value": 2},
176 | {"source": "Cochepaille", "target": "Bamatabois", "value": 1},
177 | {"source": "Pontmercy", "target": "Thenardier", "value": 1},
178 | {"source": "Boulatruelle", "target": "Thenardier", "value": 1},
179 | {"source": "Eponine", "target": "Mme.Thenardier", "value": 2},
180 | {"source": "Eponine", "target": "Thenardier", "value": 3},
181 | {"source": "Anzelma", "target": "Eponine", "value": 2},
182 | {"source": "Anzelma", "target": "Thenardier", "value": 2},
183 | {"source": "Anzelma", "target": "Mme.Thenardier", "value": 1},
184 | {"source": "Woman2", "target": "Valjean", "value": 3},
185 | {"source": "Woman2", "target": "Cosette", "value": 1},
186 | {"source": "Woman2", "target": "Javert", "value": 1},
187 | {"source": "MotherInnocent", "target": "Fauchelevent", "value": 3},
188 | {"source": "MotherInnocent", "target": "Valjean", "value": 1},
189 | {"source": "Gribier", "target": "Fauchelevent", "value": 2},
190 | {"source": "Mme.Burgon", "target": "Jondrette", "value": 1},
191 | {"source": "Gavroche", "target": "Mme.Burgon", "value": 2},
192 | {"source": "Gavroche", "target": "Thenardier", "value": 1},
193 | {"source": "Gavroche", "target": "Javert", "value": 1},
194 | {"source": "Gavroche", "target": "Valjean", "value": 1},
195 | {"source": "Gillenormand", "target": "Cosette", "value": 3},
196 | {"source": "Gillenormand", "target": "Valjean", "value": 2},
197 | {"source": "Magnon", "target": "Gillenormand", "value": 1},
198 | {"source": "Magnon", "target": "Mme.Thenardier", "value": 1},
199 | {"source": "Mlle.Gillenormand", "target": "Gillenormand", "value": 9},
200 | {"source": "Mlle.Gillenormand", "target": "Cosette", "value": 2},
201 | {"source": "Mlle.Gillenormand", "target": "Valjean", "value": 2},
202 | {"source": "Mme.Pontmercy", "target": "Mlle.Gillenormand", "value": 1},
203 | {"source": "Mme.Pontmercy", "target": "Pontmercy", "value": 1},
204 | {"source": "Mlle.Vaubois", "target": "Mlle.Gillenormand", "value": 1},
205 | {"source": "Lt.Gillenormand", "target": "Mlle.Gillenormand", "value": 2},
206 | {"source": "Lt.Gillenormand", "target": "Gillenormand", "value": 1},
207 | {"source": "Lt.Gillenormand", "target": "Cosette", "value": 1},
208 | {"source": "Marius", "target": "Mlle.Gillenormand", "value": 6},
209 | {"source": "Marius", "target": "Gillenormand", "value": 12},
210 | {"source": "Marius", "target": "Pontmercy", "value": 1},
211 | {"source": "Marius", "target": "Lt.Gillenormand", "value": 1},
212 | {"source": "Marius", "target": "Cosette", "value": 21},
213 | {"source": "Marius", "target": "Valjean", "value": 19},
214 | {"source": "Marius", "target": "Tholomyes", "value": 1},
215 | {"source": "Marius", "target": "Thenardier", "value": 2},
216 | {"source": "Marius", "target": "Eponine", "value": 5},
217 | {"source": "Marius", "target": "Gavroche", "value": 4},
218 | {"source": "BaronessT", "target": "Gillenormand", "value": 1},
219 | {"source": "BaronessT", "target": "Marius", "value": 1},
220 | {"source": "Mabeuf", "target": "Marius", "value": 1},
221 | {"source": "Mabeuf", "target": "Eponine", "value": 1},
222 | {"source": "Mabeuf", "target": "Gavroche", "value": 1},
223 | {"source": "Enjolras", "target": "Marius", "value": 7},
224 | {"source": "Enjolras", "target": "Gavroche", "value": 7},
225 | {"source": "Enjolras", "target": "Javert", "value": 6},
226 | {"source": "Enjolras", "target": "Mabeuf", "value": 1},
227 | {"source": "Enjolras", "target": "Valjean", "value": 4},
228 | {"source": "Combeferre", "target": "Enjolras", "value": 15},
229 | {"source": "Combeferre", "target": "Marius", "value": 5},
230 | {"source": "Combeferre", "target": "Gavroche", "value": 6},
231 | {"source": "Combeferre", "target": "Mabeuf", "value": 2},
232 | {"source": "Prouvaire", "target": "Gavroche", "value": 1},
233 | {"source": "Prouvaire", "target": "Enjolras", "value": 4},
234 | {"source": "Prouvaire", "target": "Combeferre", "value": 2},
235 | {"source": "Feuilly", "target": "Gavroche", "value": 2},
236 | {"source": "Feuilly", "target": "Enjolras", "value": 6},
237 | {"source": "Feuilly", "target": "Prouvaire", "value": 2},
238 | {"source": "Feuilly", "target": "Combeferre", "value": 5},
239 | {"source": "Feuilly", "target": "Mabeuf", "value": 1},
240 | {"source": "Feuilly", "target": "Marius", "value": 1},
241 | {"source": "Courfeyrac", "target": "Marius", "value": 9},
242 | {"source": "Courfeyrac", "target": "Enjolras", "value": 17},
243 | {"source": "Courfeyrac", "target": "Combeferre", "value": 13},
244 | {"source": "Courfeyrac", "target": "Gavroche", "value": 7},
245 | {"source": "Courfeyrac", "target": "Mabeuf", "value": 2},
246 | {"source": "Courfeyrac", "target": "Eponine", "value": 1},
247 | {"source": "Courfeyrac", "target": "Feuilly", "value": 6},
248 | {"source": "Courfeyrac", "target": "Prouvaire", "value": 3},
249 | {"source": "Bahorel", "target": "Combeferre", "value": 5},
250 | {"source": "Bahorel", "target": "Gavroche", "value": 5},
251 | {"source": "Bahorel", "target": "Courfeyrac", "value": 6},
252 | {"source": "Bahorel", "target": "Mabeuf", "value": 2},
253 | {"source": "Bahorel", "target": "Enjolras", "value": 4},
254 | {"source": "Bahorel", "target": "Feuilly", "value": 3},
255 | {"source": "Bahorel", "target": "Prouvaire", "value": 2},
256 | {"source": "Bahorel", "target": "Marius", "value": 1},
257 | {"source": "Bossuet", "target": "Marius", "value": 5},
258 | {"source": "Bossuet", "target": "Courfeyrac", "value": 12},
259 | {"source": "Bossuet", "target": "Gavroche", "value": 5},
260 | {"source": "Bossuet", "target": "Bahorel", "value": 4},
261 | {"source": "Bossuet", "target": "Enjolras", "value": 10},
262 | {"source": "Bossuet", "target": "Feuilly", "value": 6},
263 | {"source": "Bossuet", "target": "Prouvaire", "value": 2},
264 | {"source": "Bossuet", "target": "Combeferre", "value": 9},
265 | {"source": "Bossuet", "target": "Mabeuf", "value": 1},
266 | {"source": "Bossuet", "target": "Valjean", "value": 1},
267 | {"source": "Joly", "target": "Bahorel", "value": 5},
268 | {"source": "Joly", "target": "Bossuet", "value": 7},
269 | {"source": "Joly", "target": "Gavroche", "value": 3},
270 | {"source": "Joly", "target": "Courfeyrac", "value": 5},
271 | {"source": "Joly", "target": "Enjolras", "value": 5},
272 | {"source": "Joly", "target": "Feuilly", "value": 5},
273 | {"source": "Joly", "target": "Prouvaire", "value": 2},
274 | {"source": "Joly", "target": "Combeferre", "value": 5},
275 | {"source": "Joly", "target": "Mabeuf", "value": 1},
276 | {"source": "Joly", "target": "Marius", "value": 2},
277 | {"source": "Grantaire", "target": "Bossuet", "value": 3},
278 | {"source": "Grantaire", "target": "Enjolras", "value": 3},
279 | {"source": "Grantaire", "target": "Combeferre", "value": 1},
280 | {"source": "Grantaire", "target": "Courfeyrac", "value": 2},
281 | {"source": "Grantaire", "target": "Joly", "value": 2},
282 | {"source": "Grantaire", "target": "Gavroche", "value": 1},
283 | {"source": "Grantaire", "target": "Bahorel", "value": 1},
284 | {"source": "Grantaire", "target": "Feuilly", "value": 1},
285 | {"source": "Grantaire", "target": "Prouvaire", "value": 1},
286 | {"source": "MotherPlutarch", "target": "Mabeuf", "value": 3},
287 | {"source": "Gueulemer", "target": "Thenardier", "value": 5},
288 | {"source": "Gueulemer", "target": "Valjean", "value": 1},
289 | {"source": "Gueulemer", "target": "Mme.Thenardier", "value": 1},
290 | {"source": "Gueulemer", "target": "Javert", "value": 1},
291 | {"source": "Gueulemer", "target": "Gavroche", "value": 1},
292 | {"source": "Gueulemer", "target": "Eponine", "value": 1},
293 | {"source": "Babet", "target": "Thenardier", "value": 6},
294 | {"source": "Babet", "target": "Gueulemer", "value": 6},
295 | {"source": "Babet", "target": "Valjean", "value": 1},
296 | {"source": "Babet", "target": "Mme.Thenardier", "value": 1},
297 | {"source": "Babet", "target": "Javert", "value": 2},
298 | {"source": "Babet", "target": "Gavroche", "value": 1},
299 | {"source": "Babet", "target": "Eponine", "value": 1},
300 | {"source": "Claquesous", "target": "Thenardier", "value": 4},
301 | {"source": "Claquesous", "target": "Babet", "value": 4},
302 | {"source": "Claquesous", "target": "Gueulemer", "value": 4},
303 | {"source": "Claquesous", "target": "Valjean", "value": 1},
304 | {"source": "Claquesous", "target": "Mme.Thenardier", "value": 1},
305 | {"source": "Claquesous", "target": "Javert", "value": 1},
306 | {"source": "Claquesous", "target": "Eponine", "value": 1},
307 | {"source": "Claquesous", "target": "Enjolras", "value": 1},
308 | {"source": "Montparnasse", "target": "Javert", "value": 1},
309 | {"source": "Montparnasse", "target": "Babet", "value": 2},
310 | {"source": "Montparnasse", "target": "Gueulemer", "value": 2},
311 | {"source": "Montparnasse", "target": "Claquesous", "value": 2},
312 | {"source": "Montparnasse", "target": "Valjean", "value": 1},
313 | {"source": "Montparnasse", "target": "Gavroche", "value": 1},
314 | {"source": "Montparnasse", "target": "Eponine", "value": 1},
315 | {"source": "Montparnasse", "target": "Thenardier", "value": 1},
316 | {"source": "Toussaint", "target": "Cosette", "value": 2},
317 | {"source": "Toussaint", "target": "Javert", "value": 1},
318 | {"source": "Toussaint", "target": "Valjean", "value": 1},
319 | {"source": "Child1", "target": "Gavroche", "value": 2},
320 | {"source": "Child2", "target": "Gavroche", "value": 2},
321 | {"source": "Child2", "target": "Child1", "value": 3},
322 | {"source": "Brujon", "target": "Babet", "value": 3},
323 | {"source": "Brujon", "target": "Gueulemer", "value": 3},
324 | {"source": "Brujon", "target": "Thenardier", "value": 3},
325 | {"source": "Brujon", "target": "Gavroche", "value": 1},
326 | {"source": "Brujon", "target": "Eponine", "value": 1},
327 | {"source": "Brujon", "target": "Claquesous", "value": 1},
328 | {"source": "Brujon", "target": "Montparnasse", "value": 1},
329 | {"source": "Mme.Hucheloup", "target": "Bossuet", "value": 1},
330 | {"source": "Mme.Hucheloup", "target": "Joly", "value": 1},
331 | {"source": "Mme.Hucheloup", "target": "Grantaire", "value": 1},
332 | {"source": "Mme.Hucheloup", "target": "Bahorel", "value": 1},
333 | {"source": "Mme.Hucheloup", "target": "Courfeyrac", "value": 1},
334 | {"source": "Mme.Hucheloup", "target": "Gavroche", "value": 1},
335 | {"source": "Mme.Hucheloup", "target": "Enjolras", "value": 1}
336 | ]
337 | }
338 |
--------------------------------------------------------------------------------