├── .gitignore
├── LICENSE.txt
├── README.md
├── attribution
├── data-1996-uk-web-hosts.md
├── data-les-miserables.md
├── springy.md
└── vivagraphjs.md
├── build.sbt
├── project
├── .gitignore
├── build.properties
└── plugins.sbt
├── scala-force-layout.png
└── src
├── main
└── scala
│ └── at
│ └── ait
│ └── dme
│ └── forcelayout
│ ├── Bounds.scala
│ ├── Edge.scala
│ ├── Node.scala
│ ├── SpringGraph.scala
│ ├── Vector2D.scala
│ ├── examples
│ ├── HelloWorld.scala
│ ├── LesMiserables.scala
│ ├── LesMiserablesOpenGL.scala
│ └── UKWebHosts1996.scala
│ ├── quadtree
│ ├── Body.scala
│ ├── Quad.scala
│ └── QuadTree.scala
│ └── renderer
│ ├── BufferedInteractiveGraphRenderer.scala
│ ├── ColorPalette.scala
│ ├── GraphRenderer.scala
│ ├── ImageRenderer.scala
│ └── OpenGLInteractiveGraphRenderer.scala
└── test
└── resources
├── .gitignore
└── examples
├── 1996-uk-web-hosts.tsv.gz
└── miserables.json
/.gitignore:
--------------------------------------------------------------------------------
1 | cache
2 | classpath
3 | *.log
4 | target/
5 | target/*
6 | *.orig
7 | build
8 | build/*
9 | dist
10 | dist/*
11 | private
12 | private/*
13 | release/
14 | release/*
15 | *.iml
16 | *.ipr
17 | *.iws
18 | *.iml
19 | .idea/
20 | .idea/*
21 | *~
22 | .project
23 | .settings
24 | *.bak
25 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | Copyright (C) 2013 AIT Austrian Institute of Technology GmbH
2 |
3 | Permission is hereby granted, free of charge, to any person
4 | obtaining a copy of this software and associated documentation
5 | files (the "Software"), to deal in the Software without
6 | restriction, including without limitation the rights to use,
7 | copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the
9 | Software is furnished to do so, subject to the following
10 | conditions:
11 |
12 | The above copyright notice and this permission notice shall be
13 | included in all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Scala Force Layout
2 |
3 | _Scala Force Layout_ is a force-directed graph layout implementation in Scala. The project originally started
4 | out as a port of the [Springy](http://getspringy.com/) JavaScript graph layout code by Dennis Hotson. In
5 | addition, I added [Barnes-Hut simulation](http://en.wikipedia.org/wiki/Barnes%E2%80%93Hut_simulation) to
6 | improve performance on bigger graphs (here's [a video](http://www.screenr.com/7F7H)),
7 | and based my physics model parameters on those used in [VivaGraphJS](http://github.com/anvaka/VivaGraphJS) by
8 | Andrei Kashcha.
9 |
10 | 
11 |
12 | ## Getting Started
13 |
14 | Create a graph from collections of __nodes__ and __edges__.
15 |
16 | ```scala
17 | val nodes = Seq(
18 | Node("id_a", "Node A"),
19 | Node("id_b", "Node B"),
20 | Node("id_c", "Node C"),
21 | Node("id_d", "Node D"))
22 |
23 | val edges = Seq(
24 | Edge(nodes(0), nodes(1)),
25 | Edge(nodes(1), nodes(2)),
26 | Edge(nodes(2), nodes(3)),
27 | Edge(nodes(0), nodes(3)))
28 |
29 | val graph = new SpringGraph(nodes, edges)
30 | ```
31 |
32 | Run the layout algorithm using the ``graph.doLayout()`` method. Attach ``onIteration`` and
33 | ``onComplete`` handlers to capture intermediate and final results of the layout process.
34 |
35 | ```scala
36 | graph.doLayout(
37 | onIteration = (it => { ... do something on every layout iteration ... })
38 | onComplete = (it => { println("completed in " + it + " iterations") }))
39 | ```
40 |
41 | ### Rendering an Image
42 |
43 | The ``ImageRenderer`` is a simple utility for rendering an image of your graph. If all you
44 | want is to store an image of the final layout, this is what you're looking for:
45 |
46 | ```scala
47 | graph.doLayout(
48 | onComplete = (it => {
49 | // Renders a 500x500 pixel image of the final graph layout
50 | val image = ImageRenderer.drawGraph(graph, 500, 500)
51 |
52 | // Writes the image to a PNG file
53 | ImageIO.write(image, "png", new File("my-graph.png"))
54 | }))
55 | ```
56 |
57 | ### Opening a Viewer
58 |
59 | If you want to open your graph in a window on the screen (with mouse pan and zoom included),
60 | use this code:
61 |
62 | ```scala
63 | // Creates a zoom- and pan-able view of the graph
64 | val vis = new BufferedInteractiveGraphRenderer(graph)
65 |
66 | // Creates a JFrame, with the graph renderer in the content pane
67 | val frame = new JFrame("Les Miserables")
68 | frame.setSize(920, 720)
69 | frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)
70 | frame.getContentPane().add(vis)
71 | frame.pack()
72 |
73 | // Pops up the JFrame on the screen, and starts the layout process
74 | frame.setVisible(true)
75 | vis.start
76 | ```
77 |
78 | You may also want to take a look at the [Hello World](https://github.com/rsimon/scala-force-layout/blob/master/src/main/scala/at/ait/dme/forcelayout/examples/HelloWorld.scala)
79 | and [LesMiserables](https://github.com/rsimon/scala-force-layout/blob/master/src/main/scala/at/ait/dme/forcelayout/examples/LesMiserables.scala)
80 | examples for complete, working code.
81 |
82 | ## Current Version
83 |
84 | The current version of _Scala Force Layout_ is 0.4.0. Download the jar for Scala 2.10 here:
85 | [scala-force-layout_2.10-0.4.0.jar](http://rsimon.github.com/files/scala-force-layout_2.10-0.4.0.jar),
86 | or include it in your SBT project through the Maven Central Repository:
87 |
88 | ```scala
89 | libraryDependencies += "at.ait.dme.forcelayout" % "scala-force-layout_2.10" % "0.4.0"
90 | ```
91 |
92 | ## Building From Source & Running the Examples
93 |
94 | _Scala Force Layout_ uses [SBT](http://www.scala-sbt.org/) as a build tool. Please refer to the
95 | [SBT documentation](http://www.scala-sbt.org/release/docs/index.html) for instructions on how to
96 | install SBT on your machine. Once you have installed SBT, you can run the examples by typing ``sbt run``.
97 | To build a .jar package type ``sbt package``. To generate a project for the
98 | [Eclipse IDE](http://www.eclipse.org/), type ``sbt eclipse``.
99 |
100 | ## Future Work
101 |
102 | There are many things on the list - feel free to help out if you care to!
103 |
104 | * _"The last thing we need is another graph API."_ // TODO use the [Tinkerpop Blueprints](https://github.com/tinkerpop/blueprints/wiki) graph model
105 | * _"Speed is of the essence."_ // TODO I'm sure there is much room for performance optimization. Any thoughts & experiences welcome!
106 | * _"Where can I click?"_ // TODO create a renderer that produces an interactive graph, complete with draggable nodes and such
107 | * _"Sorry, I don't code."_ // TODO A simple command-line wrapper that opens some [GraphSON](https://github.com/tinkerpop/blueprints/wiki/GraphSON-Reader-and-Writer-Library),
108 | with no coding involved, would be nice
109 |
110 | ## License
111 |
112 | _Scala Force Layout_ is released under the [MIT License](http://en.wikipedia.org/wiki/MIT_License).
113 |
--------------------------------------------------------------------------------
/attribution/data-1996-uk-web-hosts.md:
--------------------------------------------------------------------------------
1 | The file [1996-uk-web-hosts.tsv.gz](https://github.com/rsimon/scala-force-layout/blob/master/src/test/resources/examples/1996-uk-web-hosts.tsv.gz)
2 | is a subset of the [UK Web Archive's Host Link Graph](http://data.webarchive.org.uk/opendata/ukwa.ds.2/host-linkage/). It represents a picture of
3 | which hosts have linked to which other hosts in the United Kingdom, in the year 1996. The data has been released under the
4 | [CC0 licence](http://creativecommons.org/publicdomain/zero/1.0/).
5 |
--------------------------------------------------------------------------------
/attribution/data-les-miserables.md:
--------------------------------------------------------------------------------
1 | The file [miserables.json](https://github.com/rsimon/scala-force-layout/blob/master/src/test/resources/examples/miserables.json)
2 | contains the weighted network of coappearances of characters in Victor Hugo's novel "Les Miserables".
3 | Nodes represent characters as indicated by the labels, and edges connect any pair of characters that
4 | appear in the same chapter of the book. The values on the edges are the number of such coappearances.
5 | The data on coappearances were taken from D. E. Knuth, The Stanford GraphBase: A Platform for
6 | Combinatorial Computing, Addison-Wesley, Reading, MA (1993).
7 |
8 | The group labels were transcribed from "Finding and evaluating community structure in networks"
9 | by M. E. J. Newman and M. Girvan.
10 |
11 | [http://www-cs-faculty.stanford.edu/~uno/sgb.html](http://www-cs-faculty.stanford.edu/~uno/sgb.html)
12 |
13 | [http://bl.ocks.org/mbostock/4062045](http://bl.ocks.org/mbostock/4062045)
--------------------------------------------------------------------------------
/attribution/springy.md:
--------------------------------------------------------------------------------
1 | Scala Force Layout includes code that was ported to Scala from the [Springy](http://getspringy.com/) JavaScript
2 | library by Dennis Hotson (MIT licensed).
3 |
4 | Copyright (c) 2010 Dennis Hotson
5 |
6 | Permission is hereby granted, free of charge, to any person
7 | obtaining a copy of this software and associated documentation
8 | files (the "Software"), to deal in the Software without
9 | restriction, including without limitation the rights to use,
10 | copy, modify, merge, publish, distribute, sublicense, and/or sell
11 | copies of the Software, and to permit persons to whom the
12 | Software is furnished to do so, subject to the following
13 | conditions:
14 |
15 | The above copyright notice and this permission notice shall be
16 | included in all copies or substantial portions of the Software.
17 |
18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
21 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
22 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
23 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
24 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
25 | OTHER DEALINGS IN THE SOFTWARE.
26 |
--------------------------------------------------------------------------------
/attribution/vivagraphjs.md:
--------------------------------------------------------------------------------
1 | I have based the parameters of my physics model (constants for spring forces, node repulsion, gravity, etc.) on
2 | those used in the [VivaGraphJS](http://github.com/anvaka/VivaGraphJS) JavaScript library by Andrei Kashcha.
3 | While _Scala Force Layout_ does not include any code portions from this library, I still think it's appropriate
4 | to acknowlege Andrei Kashcha's work here.
5 |
6 | [VivaGraphJS](http://github.com/anvaka/VivaGraphJS) is BSD-licensed, according to the following terms:
7 |
8 | Copyright (c) 2011, Andrei Kashcha
9 | All rights reserved.
10 |
11 | Redistribution and use in source and binary forms, with or without
12 | modification, are permitted provided that the following conditions are met:
13 |
14 | * Redistributions of source code must retain the above copyright notice, this
15 | list of conditions and the following disclaimer.
16 |
17 | * Redistributions in binary form must reproduce the above copyright notice,
18 | this list of conditions and the following disclaimer in the documentation
19 | and/or other materials provided with the distribution.
20 |
21 | * The name Andrei Kashcha may not be used to endorse or promote products
22 | derived from this software without specific prior written permission.
23 |
24 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
25 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
26 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
27 | DISCLAIMED. IN NO EVENT SHALL ANDREI KASHCHA BE LIABLE FOR ANY DIRECT,
28 | INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
29 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
30 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
31 | OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
32 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
33 | EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
34 |
--------------------------------------------------------------------------------
/build.sbt:
--------------------------------------------------------------------------------
1 | organization := "at.ait.dme.forcelayout"
2 |
3 | name := "scala-force-layout"
4 |
5 | version := "0.4.1-SNAPSHOT"
6 |
7 | scalaVersion := "2.11.8"
8 |
9 | publishMavenStyle := true
10 |
11 | libraryDependencies ++= Seq(
12 | "com.propensive" % "rapture-io" % "0.7.2"
13 | )
14 |
15 | // Extras for publishing to Sonatype Maven repository
16 | // Use 'sbt publish-signed' to publish
17 | // Read more: http://www.scala-sbt.org/0.12.3/docs/Community/Using-Sonatype.html
18 | // and: https://docs.sonatype.org/display/Repository/Sonatype+OSS+Maven+Repository+Usage+Guide#SonatypeOSSMavenRepositoryUsageGuide-8.ReleaseIt
19 | // Nexus UI is at: https://oss.sonatype.org/
20 |
21 | publishTo in ThisBuild := {
22 | val nexus = "https://oss.sonatype.org/"
23 | if (isSnapshot.value)
24 | Some("snapshots" at nexus + "content/repositories/snapshots")
25 | else
26 | Some("releases" at nexus + "service/local/staging/deploy/maven2")
27 | }
28 |
29 | pomIncludeRepository := { _ => false }
30 |
31 | pomExtra := (
32 | http://github.com/rsimon/scala-force-layout
33 |
34 |
35 | MIT
36 | http://opensource.org/licenses/MIT
37 | repo
38 |
39 |
40 |
41 | https://github.com/rsimon/scala-force-layout.git
42 | scm:git:git@github.com:rsimon/scala-force-layout.git
43 |
44 |
45 |
46 | rsimon
47 | Rainer Simon
48 | http://rsimon.github.com
49 |
50 | )
51 |
--------------------------------------------------------------------------------
/project/.gitignore:
--------------------------------------------------------------------------------
1 | /project
2 | /target
3 |
--------------------------------------------------------------------------------
/project/build.properties:
--------------------------------------------------------------------------------
1 | sbt.version=0.13.13
2 |
--------------------------------------------------------------------------------
/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.1.0")
2 |
3 | addSbtPlugin("com.typesafe.sbt" % "sbt-pgp" % "0.8")
4 |
--------------------------------------------------------------------------------
/scala-force-layout.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rsimon/scala-force-layout/d297da7218b8da2bb4cb659e7b9c71d44c4caff5/scala-force-layout.png
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/Bounds.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout
2 |
3 | /**
4 | * 2D bounds, plus some convenience methods.
5 | * @author Rainer Simon
6 | */
7 | case class Bounds(minX: Double, minY: Double, maxX: Double, maxY: Double) {
8 |
9 | lazy val width = maxX - minX
10 |
11 | lazy val height = maxY - minY
12 |
13 | lazy val center = Vector2D((minX + maxX) / 2, (minY + maxY) / 2)
14 |
15 | lazy val area = width * height
16 |
17 | def contains(pt: Vector2D) = {
18 | if (pt.x < minX)
19 | false
20 | else if (pt.x > maxX)
21 | false
22 | else if (pt.y < minY)
23 | false
24 | else if (pt.y > maxY)
25 | false
26 | else
27 | true
28 | }
29 |
30 | }
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/Edge.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout
2 |
3 | /**
4 | * An edge in the force layout simulation.
5 | * @author Rainer Simon
6 | */
7 | case class Edge(from: Node, to: Node, weight: Double = 1.0)
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/Node.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout
2 |
3 | /**
4 | * A node in the force layout simulation. The node has an immutable component, representing the actual
5 | * graph node, and a mutable 'state' field, containing the force simulation state.
6 | * @author Rainer Simon
7 | */
8 | case class Node private[forcelayout] (id: String, label: String, mass: Double, group: Int, inlinks: Seq[Edge], outlinks: Seq[Edge], state: NodeState) {
9 |
10 | def this(id: String, label: String, mass: Double = 1.0, group: Int = 0) =
11 | this(id, label, mass, group, Seq.empty[Edge], Seq.empty[Edge], NodeState())
12 |
13 | lazy val links = inlinks ++ outlinks
14 |
15 | }
16 |
17 | object Node {
18 | // Shortcut, so the auxiliary constructor works in the normal case-class way
19 | def apply(id: String, label: String, mass: Double = 1.0, group: Int = 0) = new Node(id, label, mass, group)
20 | }
21 |
22 | /**
23 | * A container for the (mutable) force simulation state of a graph node.
24 | * @author Rainer Simon
25 | */
26 | case class NodeState(var pos: Vector2D = Vector2D.random(1.0), var velocity: Vector2D = Vector2D(0, 0), var force: Vector2D = Vector2D(0, 0))
27 |
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/SpringGraph.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout
2 |
3 | import at.ait.dme.forcelayout.quadtree.{ Body, Quad, QuadTree }
4 |
5 | import scala.collection.parallel.ParSeq
6 |
7 | /**
8 | * A force directed graph layout implementation. Parts of this code are ported from the Springy
9 | * JavaScript library (http://getspringy.com/) by Dennis Hotson. Physics model parameters are based
10 | * on those used in the JavaScript libary VivaGraphJS (https://github.com/anvaka/VivaGraphJS) by
11 | * Andrei Kashcha.
12 | * @author Rainer Simon
13 | */
14 | class SpringGraph(val sourceNodes: Seq[Node], val sourceEdges: Seq[Edge]) {
15 |
16 | /** Repulsion constant **/
17 | private var REPULSION = -1.2
18 |
19 | /** 'Gravity' constant pulling towards origin **/
20 | private var CENTER_GRAVITY = -1e-4
21 |
22 | /** Default spring length **/
23 | private var SPRING_LENGTH = 50.0
24 |
25 | /** Spring stiffness constant **/
26 | private var SPRING_COEFFICIENT = 0.0002
27 |
28 | /** Drag coefficient **/
29 | private var DRAG = -0.02
30 |
31 | /** Time-step increment **/
32 | private val TIMESTEP = 20
33 |
34 | /** Node velocity limit **/
35 | private val MAX_VELOCITY = 1.0
36 |
37 | /** Barnes-Hut Theta Threshold **/
38 | private val THETA = 0.8
39 |
40 | /** The graph model **/
41 | val (nodes, edges) = buildGraph(sourceNodes, sourceEdges)
42 | val nodes_parallel = nodes.par
43 |
44 | /** 'Getters' and 'setters' for physics model parameters **/
45 | def repulsion = REPULSION
46 | def repulsion_=(value: Double) = REPULSION = value
47 |
48 | def centerGravity = CENTER_GRAVITY
49 | def centerGravity_=(value: Double) = CENTER_GRAVITY = value
50 |
51 | def springCoefficient = SPRING_COEFFICIENT
52 | def springCoefficient_=(value: Double) = SPRING_COEFFICIENT = value
53 |
54 | def springLength = SPRING_LENGTH
55 | def springLength_=(value: Double) = SPRING_LENGTH = value
56 |
57 | def dragCoefficient = DRAG
58 | def dragCoefficient_=(value: Double) = DRAG = value
59 |
60 | private def buildGraph(sourceNodes: Seq[Node], sourceEdges: Seq[Edge]) = {
61 | val inLinkTable = sourceEdges.groupBy(_.to.id)
62 | val outLinkTable = sourceEdges.groupBy(_.from.id)
63 |
64 | // Recompute nodes, with corrected mass (an old VivaGraphJS trick) and in/outlink data
65 | val nodes = sourceNodes.par.map(n => {
66 | val inlinks = inLinkTable.get(n.id).getOrElse(Seq.empty[Edge])
67 | val outlinks = outLinkTable.get(n.id).getOrElse(Seq.empty[Edge])
68 |
69 | val links: Double = inlinks.size + outlinks.size
70 | val mass = n.mass * (1 + links / 3)
71 |
72 | (n.id -> (Node(n.id, n.label, mass, n.group, inlinks, outlinks, NodeState())))
73 | }).seq.toMap
74 |
75 | // Re-wire edges to recomputed nodes
76 | val edges = sourceEdges.map(edge => {
77 | val fromNode = nodes.get(edge.from.id)
78 | val toNode = nodes.get(edge.to.id)
79 | Edge(fromNode.get, toNode.get, edge.weight)
80 | })
81 |
82 | (nodes.values.toSeq, edges)
83 | }
84 |
85 | private def step = {
86 | computeHookesLaw(edges)
87 | computeBarnesHut(nodes, nodes_parallel)
88 | computeDrag(nodes_parallel)
89 | computeGravity(nodes_parallel)
90 |
91 | nodes_parallel.foreach(node => {
92 | val acceleration = node.state.force / node.mass
93 | node.state.force = Vector2D(0, 0)
94 | node.state.velocity += acceleration * TIMESTEP
95 | if (node.state.velocity.magnitude > MAX_VELOCITY)
96 | node.state.velocity = node.state.velocity.normalize * MAX_VELOCITY
97 |
98 | node.state.pos += node.state.velocity * TIMESTEP
99 | })
100 | }
101 |
102 | private def computeHookesLaw(edges: Seq[Edge]) = edges.foreach(edge => {
103 | val d = if (edge.to.state.pos == edge.from.state.pos)
104 | Vector2D.random(0.1, edge.from.state.pos)
105 | else
106 | edge.to.state.pos - edge.from.state.pos
107 |
108 | val displacement = d.magnitude - SPRING_LENGTH / edge.weight
109 | val coeff = SPRING_COEFFICIENT * displacement / d.magnitude
110 | val force = d * coeff * 0.5
111 |
112 | edge.from.state.force += force
113 | edge.to.state.force -= force
114 | })
115 |
116 | private def computeBarnesHut(nodes: Seq[Node], nodes_parallel: ParSeq[Node]) = {
117 | val quadtree = new QuadTree(bounds, nodes.map(n => Body(n.state.pos, Some(n))))
118 |
119 | def apply(node: Node, quad: Quad[Node]): Unit = {
120 | val s = (quad.bounds.width + quad.bounds.height) / 2
121 | val d = (quad.center - node.state.pos).magnitude
122 | if (s/d > THETA) {
123 | // Nearby quad
124 | if (quad.children.isDefined) {
125 | quad.children.get.foreach(child => apply(node, child))
126 | } else if (quad.body.isDefined) {
127 | val d = quad.body.get.pos - node.state.pos
128 | val distance = d.magnitude
129 | val direction = d.normalize
130 |
131 | if (quad.body.get.data.get.asInstanceOf[Node] != node) {
132 | node.state.force += direction * REPULSION / (distance * distance * 0.5)
133 | }
134 | } else {
135 | Vector2D(0,0)
136 | }
137 | } else {
138 | // Far-away quad
139 | val d = quad.center - node.state.pos
140 | val distance = d.magnitude
141 | val direction = d.normalize
142 | node.state.force += direction * REPULSION * quad.bodies / (distance * distance * 0.5)
143 | }
144 | }
145 |
146 | nodes_parallel.foreach(node => apply(node, quadtree.root))
147 | }
148 |
149 | private def computeDrag(nodes: ParSeq[Node]) = nodes.foreach(node => node.state.force += node.state.velocity * DRAG)
150 |
151 | private def computeGravity(nodes: ParSeq[Node]) = nodes.foreach(node => node.state.force += node.state.pos.normalize * CENTER_GRAVITY * node.mass)
152 |
153 | def bounds = {
154 | val positions = nodes_parallel.map(n => (n.state.pos.x, n.state.pos.y))
155 | val minX = positions.minBy(_._1)._1
156 | val minY = positions.minBy(_._2)._2
157 | val maxX = positions.maxBy(_._1)._1
158 | val maxY = positions.maxBy(_._2)._2
159 |
160 | Bounds(minX, minY, maxX, maxY)
161 | }
162 |
163 | def totalEnergy = nodes_parallel.map(node => {
164 | val v = node.state.velocity.magnitude
165 | 0.5 * node.mass * v * v
166 | }).fold(0.0)(_ + _)
167 |
168 | def getNearestNode(pt: Vector2D) = nodes.map(node => (node, (node.state.pos - pt).magnitude)).sortBy(_._2).head._1
169 |
170 | def doLayout(onComplete: (Int => Unit) = null, onIteration: (Int => Unit) = null, maxIterations: Int = 1000): Unit = {
171 | var it = 0
172 | do {
173 | step
174 |
175 | if (onIteration != null)
176 | onIteration(it)
177 | it += 1
178 | } while (totalEnergy > 0.001 && it < maxIterations)
179 |
180 | if (onComplete != null)
181 | onComplete(it)
182 | }
183 |
184 | }
185 |
186 |
187 |
188 |
189 |
190 |
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/Vector2D.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout
2 |
3 | import scala.util.Random
4 |
5 | /**
6 | * A basic 2D vector, plus some convenience methods.
7 | * @author Rainer Simon
8 | */
9 | case class Vector2D(val x: Double, val y: Double ) {
10 |
11 | def add(v: Vector2D) = Vector2D(x + v.x, y + v.y)
12 |
13 | def +(v: Vector2D) = Vector2D.this.add(v)
14 |
15 | def substract(v: Vector2D) = Vector2D(x - v.x, y - v.y)
16 |
17 | def -(v: Vector2D) = Vector2D.this.substract(v)
18 |
19 | def multiply(n: Double) = Vector2D(x * n, y * n)
20 |
21 | def *(n: Double) = Vector2D.this.multiply(n)
22 |
23 | def divide(n: Double) = Vector2D(x / n, y /n)
24 |
25 | def /(n: Double) = Vector2D.this.divide(n)
26 |
27 | lazy val magnitude = Math.sqrt(x * x + y * y)
28 |
29 | lazy val normalize = Vector2D.this.divide(Vector2D.this.magnitude)
30 |
31 | }
32 |
33 | object Vector2D {
34 |
35 | def random(r: Double = 1.0, center: Vector2D = Vector2D(0, 0)) = Vector2D(center.x + Random.nextDouble * r - r / 2, center.y + Random.nextDouble * r - r / 2)
36 |
37 | }
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/examples/HelloWorld.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.examples
2 |
3 | import java.awt.Dimension
4 | import javax.swing.{ JFrame, JLabel, ImageIcon }
5 |
6 | import at.ait.dme.forcelayout.renderer.ImageRenderer
7 | import at.ait.dme.forcelayout.{ Node, Edge, SpringGraph }
8 |
9 | object HelloWorld extends App {
10 |
11 | val nodes = Seq(
12 | Node("A", "Node A"),
13 | Node("B", "Node B"),
14 | Node("C", "Node C"),
15 | Node("D", "Node D"))
16 |
17 | val edges = Seq(
18 | Edge(nodes(0), nodes(1)),
19 | Edge(nodes(1), nodes(2)),
20 | Edge(nodes(2), nodes(3)),
21 | Edge(nodes(0), nodes(3)))
22 |
23 | val graph = new SpringGraph(nodes, edges)
24 |
25 | val frame = new JFrame()
26 | frame.setPreferredSize(new Dimension(500,500))
27 | val imgIcon = new ImageIcon()
28 | val imgLabel = new JLabel(imgIcon)
29 | frame.add(imgLabel)
30 | frame.pack();
31 | frame.setVisible(true);
32 |
33 | graph.doLayout(
34 | onComplete = (it => println("completed in " + it + " iterations")),
35 | onIteration = (it => imgLabel.setIcon(new ImageIcon(ImageRenderer.drawGraph(graph, 500, 500)))))
36 |
37 | }
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/examples/LesMiserables.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.examples
2 |
3 | import rapture.io._
4 | import scala.io.Source
5 | import javax.swing.JFrame
6 | import java.awt.{ BasicStroke, Color, Dimension, Graphics2D }
7 | import java.awt.geom.Ellipse2D
8 | import at.ait.dme.forcelayout.{ Edge, Node, SpringGraph }
9 | import at.ait.dme.forcelayout.renderer.{ BufferedInteractiveGraphRenderer, Node2D }
10 | import at.ait.dme.forcelayout.renderer.ColorPalette
11 |
12 | object LesMiserables extends App {
13 |
14 | val json = Json.parse(Source.fromFile("src/test/resources/examples/miserables.json").mkString)
15 |
16 | val nodes: Seq[Node] = json.nodes.get[List[Json]].map(json => {
17 | val name = json.name.get[String].toString
18 | val group = json.group.get[Int]
19 | Node(name, name, 1.0, group)
20 | })
21 |
22 | val edges = json.links.get[List[Json]].map(json => {
23 | val value = json.value.get[Int]
24 | Edge(nodes(json.source.get[Int]), nodes(json.target.get[Int]), value.toDouble)
25 | })
26 |
27 | val graph = new SpringGraph(nodes, edges)
28 |
29 | val vis = new BufferedInteractiveGraphRenderer(graph)
30 |
31 | val nodePainter = (nodes: Seq[Node2D], g2d: Graphics2D) => {
32 | nodes.foreach(n2d => {
33 | val (x, y, n) = (n2d.x, n2d.y, n2d.node)
34 | val size = 6 + (n.links.size / 2)
35 | g2d.setColor(ColorPalette.getColor(n.group))
36 | g2d.fill(new Ellipse2D.Double(x - size / 2, y - size / 2, size, size))
37 | g2d.setStroke(new BasicStroke(2));
38 | g2d.setColor(Color.WHITE)
39 | g2d.draw(new Ellipse2D.Double(x - size / 2, y - size / 2, size, size))
40 | })
41 | }
42 | vis.setNodePainter(nodePainter)
43 |
44 | val frame = new JFrame("Les Miserables")
45 | frame.setPreferredSize(new Dimension(920,720))
46 | frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)
47 | frame.getContentPane().add(vis)
48 | frame.pack()
49 | frame.setVisible(true)
50 |
51 | vis.start
52 |
53 | }
54 |
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/examples/LesMiserablesOpenGL.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.examples
2 |
3 | import rapture.io._
4 | import scala.io.Source
5 |
6 | import java.awt.Dimension
7 | import javax.swing.JFrame
8 |
9 | import at.ait.dme.forcelayout.{ Edge, Node, SpringGraph }
10 | import at.ait.dme.forcelayout.renderer.OpenGLInteractiveGraphRenderer
11 |
12 | object LesMiserablesOpenGL extends App {
13 |
14 | val json = Json.parse(Source.fromFile("src/test/resources/examples/miserables.json").mkString)
15 |
16 | val nodes: Seq[Node] = json.nodes.get[List[Json]].map(json => {
17 | val name = json.name.get[String].toString
18 | val group = json.group.get[Int]
19 | Node(name, name, 1.0, group)
20 | })
21 |
22 | val edges = json.links.get[List[Json]].map(json => {
23 | val value = json.value.get[Int]
24 | Edge(nodes(json.source.get[Int]), nodes(json.target.get[Int]), value.toDouble)
25 | })
26 |
27 | val graph = new SpringGraph(nodes, edges)
28 |
29 | val vis = new OpenGLInteractiveGraphRenderer(graph)
30 |
31 | val frame = new JFrame("Les Miserables")
32 | frame.setPreferredSize(new Dimension(920,720))
33 | frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)
34 | frame.getContentPane().add(vis)
35 | frame.pack()
36 | frame.setVisible(true)
37 |
38 | vis.start
39 |
40 | }
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/examples/UKWebHosts1996.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.examples
2 |
3 | import scala.io.Source
4 | import java.io.BufferedInputStream
5 | import java.io.FileInputStream
6 | import java.util.zip.GZIPInputStream
7 |
8 | import javax.swing.JFrame
9 | import java.awt.{ Dimension, Graphics2D }
10 |
11 | import at.ait.dme.forcelayout.{ Node, Edge, SpringGraph }
12 | import at.ait.dme.forcelayout.renderer.{ BufferedInteractiveGraphRenderer, Edge2D }
13 |
14 | object UKWebHosts1996 extends App {
15 |
16 | print("Reading nodes ")
17 | val nodes = readGZippedData.map(record => {
18 | val from = record(1)
19 | val to = record(2).split("\t")(0)
20 | Seq(Node(from, from), Node(to, to))
21 | }).flatten.toSeq.groupBy(_.id).mapValues(_.head)
22 | println("- " + nodes.size)
23 |
24 | print("Reading edges ")
25 | val edges = readGZippedData.map(record => {
26 | val from = nodes.get(record(1))
27 | val to = nodes.get(record(2).split("\t")(0))
28 | val size = record(2).split("\t")(1).toInt
29 | if (from.isDefined && to.isDefined)
30 | Edge(from.get, to.get, size)
31 | else
32 | null
33 | }).filter(_ != null).toSeq
34 | println("- " + edges.size)
35 | val graph = new SpringGraph(nodes.values.toSeq, edges)
36 |
37 | val vis = new BufferedInteractiveGraphRenderer(graph)
38 |
39 | val frame = new JFrame("1996 UK Web Hosts")
40 | frame.setPreferredSize(new Dimension(920,720))
41 | frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)
42 | frame.getContentPane().add(vis)
43 | frame.pack()
44 | frame.setVisible(true)
45 |
46 | println("Omitting edges for faster drawing...")
47 | vis.setEdgePainter((edges: Seq[Edge2D], g2d: Graphics2D) => { /** Do nothing **/ })
48 | vis.start
49 |
50 | def readGZippedData =
51 | Source.fromInputStream(new GZIPInputStream(new BufferedInputStream(new FileInputStream("src/test/resources/examples/1996-uk-web-hosts.tsv.gz")))).getLines.map(_.split("\\|"))
52 |
53 | }
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/quadtree/Body.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.quadtree
2 |
3 | import at.ait.dme.forcelayout.Vector2D
4 |
5 | /**
6 | * A body in the quadtree.
7 | * @author Rainer Simon
8 | */
9 | case class Body[T](pos: Vector2D, data: Option[T] = None)
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/quadtree/Quad.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.quadtree
2 |
3 | import at.ait.dme.forcelayout.{ Bounds, Vector2D }
4 |
5 | /**
6 | * A quad in the quadtree.
7 | * @author Rainer Simon
8 | */
9 | case class Quad[T](
10 | bounds: Bounds,
11 | center: Vector2D,
12 | bodies: Int,
13 | body: Option[Body[T]] = None,
14 | children: Option[Seq[Quad[T]]] = None)
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/quadtree/QuadTree.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.quadtree
2 |
3 | import at.ait.dme.forcelayout.{ Bounds, Vector2D }
4 |
5 | /**
6 | * An immutable quadtree implementation.
7 | * @author Rainer Simon
8 | */
9 | class QuadTree[T](bounds: Bounds, bodies: Seq[Body[T]]) {
10 |
11 | import QuadTree._
12 |
13 | val root = build(bounds, bodies)
14 |
15 | def build(bounds: Bounds, bodies: Seq[Body[T]]): Quad[T] = {
16 | if (bodies.isEmpty) {
17 | Quad(bounds, bounds.center, 0)
18 | } else if (bodies.size == 1) {
19 | val body = bodies.head
20 | Quad(bounds, body.pos, 1, Some(body))
21 | } else {
22 | val children = subdivideBounds(bounds)
23 | .map(subbounds => build(subbounds, clipBodies(bodies, subbounds)))
24 | Quad(bounds, computeCenter(bodies), bodies.size, None, Some(children))
25 | }
26 | }
27 |
28 | }
29 |
30 | object QuadTree {
31 |
32 | def subdivideBounds(bounds: Bounds) = Seq(
33 | Bounds(bounds.minX, bounds.minY + bounds.height / 2, bounds.minX + bounds.width / 2, bounds.maxY),
34 | Bounds(bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2, bounds.maxX, bounds.maxY),
35 | Bounds(bounds.minX + bounds.width / 2, bounds.minY, bounds.maxX, bounds.minY + bounds.height / 2),
36 | Bounds(bounds.minX, bounds.minY, bounds.minX + bounds.width / 2, bounds.minY + bounds.height / 2))
37 |
38 | def clipBodies[T](bodies: Seq[Body[T]], bounds: Bounds) = bodies.filter(b => bounds.contains(b.pos))
39 |
40 | def computeCenter[T](bodies: Seq[Body[T]]) = {
41 | val x = bodies.map(_.pos.x).fold(0.0)(_ + _) / bodies.size
42 | val y = bodies.map(_.pos.y).fold(0.0)(_ + _) / bodies.size
43 | Vector2D(x,y)
44 | }
45 |
46 | }
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/renderer/BufferedInteractiveGraphRenderer.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.renderer
2 |
3 | import java.awt.{ Canvas, Dimension, Image, Graphics, Graphics2D, GraphicsEnvironment, Point, RenderingHints }
4 | import java.awt.event.{ KeyAdapter, KeyEvent, MouseAdapter, MouseEvent, MouseWheelListener, MouseWheelEvent }
5 | import at.ait.dme.forcelayout.{ Node, SpringGraph }
6 |
7 | class BufferedInteractiveGraphRenderer(graph: SpringGraph) extends Canvas with GraphRenderer {
8 |
9 | private var offscreenImage: Image = null
10 | private var offscreenGraphics: Graphics2D = null
11 | private var offscreenDimension: Dimension = null
12 |
13 | private var currentZoom = 1.0
14 | private var currentXOffset = 0.0
15 | private var currentYOffset = 0.0
16 | private var lastMousePos = new Point(0, 0)
17 |
18 | private var selectedNode: Option[Node] = None
19 |
20 | private var showLabels = false
21 |
22 | addKeyListener(new KeyAdapter() {
23 | override def keyPressed(e: KeyEvent) {
24 | if (e.getKeyCode == 76) {
25 | showLabels = !showLabels
26 | repaint()
27 | }
28 | }
29 | })
30 |
31 | addMouseMotionListener(new MouseAdapter() {
32 | override def mouseDragged(e: MouseEvent) {
33 | currentXOffset += e.getX - lastMousePos.getX
34 | currentYOffset += e.getY - lastMousePos.getY
35 | lastMousePos = e.getPoint
36 | repaint()
37 | }
38 | })
39 |
40 | addMouseListener(new MouseAdapter() {
41 | override def mouseClicked(e: MouseEvent) {
42 | val size = getSize()
43 | val coords = toGraphCoords(graph, e.getPoint, size.getWidth.toInt, size.getHeight.toInt, currentXOffset, currentYOffset, currentZoom)
44 | selectedNode = Some(graph.getNearestNode(coords))
45 | repaint()
46 | }
47 |
48 | override def mousePressed(e: MouseEvent) = lastMousePos = e.getPoint
49 | })
50 |
51 | addMouseWheelListener(new MouseWheelListener() {
52 | override def mouseWheelMoved(e: MouseWheelEvent) {
53 | // TODO make zooming sensitive to mouse position
54 | if (e.getWheelRotation() > 0)
55 | currentZoom /= 1.1
56 | else
57 | currentZoom *= 1.1
58 |
59 | repaint()
60 | }
61 | })
62 |
63 | override def paint(g : Graphics) {
64 | val currentSize = getSize
65 | val (width, height) = (currentSize.getWidth.toInt, currentSize.getWidth.toInt)
66 | val gfxConfig = GraphicsEnvironment.getLocalGraphicsEnvironment.getDefaultScreenDevice.getDefaultConfiguration
67 |
68 | if(offscreenImage == null || !currentSize.equals(offscreenDimension)) {
69 | if (offscreenImage != null)
70 | offscreenGraphics.dispose
71 |
72 | offscreenImage = gfxConfig.createCompatibleImage(currentSize.width, currentSize.height)
73 | offscreenGraphics = offscreenImage.getGraphics.asInstanceOf[Graphics2D]
74 | offscreenDimension = currentSize
75 | offscreenGraphics.setRenderingHint(
76 | RenderingHints.KEY_ANTIALIASING,
77 | RenderingHints.VALUE_ANTIALIAS_ON)
78 | offscreenGraphics.setRenderingHint(
79 | RenderingHints.KEY_FRACTIONALMETRICS,
80 | RenderingHints.VALUE_FRACTIONALMETRICS_ON)
81 | }
82 |
83 | render(offscreenGraphics, graph, currentSize.getWidth.toInt, currentSize.getHeight.toInt, selectedNode, currentXOffset, currentYOffset, currentZoom, showLabels)
84 | g.drawImage(offscreenImage, 0, 0, this)
85 | }
86 |
87 | override def update(g: Graphics) = paint(g)
88 |
89 | def start = graph.doLayout(onComplete = (it => { println("completed in " + it + " iterations"); repaint() }),
90 | onIteration = (it => repaint()))
91 | }
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/renderer/ColorPalette.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.renderer
2 |
3 | import java.awt.Color
4 |
5 | /**
6 | * Color palette re-uses colors from D3 - http://d3js.org/
7 | */
8 | object ColorPalette {
9 |
10 | private val palette = Seq(
11 | new Color(31, 119, 180),
12 | new Color(255, 127, 14),
13 | new Color(44, 160, 44),
14 | new Color(214, 39, 40),
15 | new Color(148, 103, 189),
16 | new Color(140, 86, 75),
17 | new Color(227, 119, 194),
18 | new Color(127, 127, 127),
19 | new Color(188, 189, 34),
20 | new Color(23, 190, 207))
21 |
22 | def getColor(idx: Int) = palette(idx % palette.size)
23 |
24 | }
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/renderer/GraphRenderer.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.renderer
2 |
3 | import java.awt._
4 | import java.awt.geom.Ellipse2D
5 | import at.ait.dme.forcelayout.{ Node, Edge, SpringGraph, Vector2D }
6 |
7 | class Node2D(val x: Int, val y: Int, val node: Node)
8 |
9 | class Edge2D(val from: Node2D, val to: Node2D, val edge: Edge)
10 |
11 | private[renderer] trait GraphRenderer {
12 |
13 | private var lastCompletion: Long = System.currentTimeMillis
14 |
15 | private var nodePainter = (nodes: Seq[Node2D], g2d: Graphics2D) => {
16 | nodes.foreach(n2d => {
17 | val (x, y, n) = (n2d.x, n2d.y, n2d.node)
18 | val size = Math.max(6, Math.min(30, Math.log(n.mass) + 1))
19 | g2d.setColor(ColorPalette.getColor(n.group))
20 | g2d.fill(new Ellipse2D.Double(x - size / 2, y - size / 2, size, size))
21 | })
22 | }
23 |
24 | def setNodePainter(painter: (Seq[Node2D], Graphics2D) => Unit) =
25 | nodePainter = painter
26 |
27 | private var edgePainter = (edges: Seq[Edge2D], g2d: Graphics2D) => {
28 | edges.foreach(e2d => {
29 | val width = Math.min(4, Math.max(2, Math.min(8, e2d.edge.weight)).toInt / 2)
30 | g2d.setStroke(new BasicStroke(width));
31 | g2d.setColor(new Color(198, 198, 198, 198))
32 | g2d.drawLine(e2d.from.x, e2d.from.y, e2d.to.x, e2d.to.y)
33 | })
34 | }
35 |
36 | def setEdgePainter(painter: (Seq[Edge2D], Graphics2D) => Unit) =
37 | edgePainter = painter
38 |
39 | def render(g2d: Graphics2D, graph: SpringGraph, width: Int, height: Int, selectedNode: Option[Node] = None, offsetX: Double = 0.0, offsetY: Double = 0.0, zoom: Double = 1.0, showLabels: Boolean = false): Unit = {
40 | g2d.setColor(Color.WHITE)
41 | g2d.fillRect(0, 0, width, height)
42 |
43 | val c = computeScale(graph, width, height) * zoom
44 | val (dx, dy) = (width / 2 + offsetX, height / 2 + offsetY)
45 |
46 | val edges2D = graph.edges.map(e => {
47 | val from = new Node2D((c * e.from.state.pos.x + dx).toInt, (c * e.from.state.pos.y + dy).toInt, e.from)
48 | val to = new Node2D((c * e.to.state.pos.x + dx).toInt, (c * e.to.state.pos.y + dy).toInt, e.to)
49 | new Edge2D(from, to, e)
50 | })
51 | edgePainter(edges2D, g2d)
52 |
53 | val nodes2D = graph.nodes.map(n => new Node2D((c * n.state.pos.x + dx).toInt, (c * n.state.pos.y + dy).toInt, n))
54 | .filter(n2d => n2d.x > 0 && n2d.y > 0)
55 | .filter(n2d => n2d.x <= width && n2d.y <= height)
56 | nodePainter(nodes2D, g2d)
57 |
58 | if (showLabels) {
59 | g2d.setColor(Color.BLACK)
60 | nodes2D.foreach(n2d => g2d.drawString(n2d.node.label, n2d.x + 5, n2d.y - 2))
61 | }
62 |
63 | if (selectedNode.isDefined) {
64 | val n = selectedNode.get
65 | val size = Math.log(n.mass) + 7
66 | val px = c * n.state.pos.x + dx
67 | val py = c * n.state.pos.y + dy
68 |
69 | // Highlight in-links
70 | graph.edges.filter(_.to.id.equals(n.id)).foreach(e => {
71 | val from = (c * e.from.state.pos.x + dx, c * e.from.state.pos.y + dy)
72 | val width = Math.min(4, Math.max(2, Math.min(8, e.weight)).toInt / 2)
73 |
74 | g2d.setStroke(new BasicStroke(width));
75 | g2d.setColor(Color.GREEN)
76 | g2d.drawLine(from._1.toInt, from._2.toInt, px.toInt, py.toInt)
77 | g2d.setColor(Color.BLACK)
78 | g2d.drawString(e.from.label, from._1.toInt + 5, from._2.toInt - 2)
79 | })
80 |
81 | // Highlight out-links
82 | graph.edges.filter(_.from.id.equals(n.id)).foreach(e => {
83 | val to = (c * e.to.state.pos.x + dx, c * e.to.state.pos.y + dy)
84 | val width = Math.min(4, Math.max(2, Math.min(8, e.weight)).toInt / 2)
85 |
86 | g2d.setStroke(new BasicStroke(width));
87 | g2d.setColor(Color.RED)
88 | g2d.drawLine(px.toInt, py.toInt, to._1.toInt, to._2.toInt)
89 | g2d.setColor(Color.BLACK)
90 | g2d.drawString(e.to.label, to._1.toInt + 5, to._2.toInt - 2)
91 | })
92 |
93 | g2d.setColor(Color.BLACK);
94 | g2d.draw(new Ellipse2D.Double(px - size / 2, py - size / 2, size, size))
95 | g2d.drawString(n.label, px.toInt + 5, py.toInt - 2)
96 | }
97 |
98 | g2d.setColor(Color.BLACK)
99 | g2d.drawString("%.1f".format(1000.0 / (System.currentTimeMillis - lastCompletion)) + "FPS", 2, 12)
100 |
101 | lastCompletion = System.currentTimeMillis
102 | }
103 |
104 | private def computeScale(graph: SpringGraph, width: Int, height: Int) = {
105 | val bounds = graph.bounds
106 | Math.min(width / 2 * 0.9 / Math.max(bounds.maxX, Math.abs(bounds.minX)), height / 2 * 0.9 / Math.max(bounds.maxY, Math.abs(bounds.minY)))
107 | }
108 |
109 | def toGraphCoords(graph: SpringGraph, pt: Point, width: Int, height: Int, offsetX: Double = 0.0, offsetY: Double = 0.0, zoom: Double = 1.0): Vector2D = {
110 | val c = computeScale(graph, width, height)
111 | val gx = (pt.x - width / 2 - offsetX) / (c * zoom)
112 | val gy = (pt.y - height / 2 - offsetY) / (c * zoom)
113 | Vector2D(gx, gy)
114 | }
115 |
116 | }
117 |
118 |
119 |
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/renderer/ImageRenderer.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.renderer
2 |
3 | import java.awt.{ Graphics2D, RenderingHints }
4 | import java.awt.image.BufferedImage
5 |
6 | import at.ait.dme.forcelayout.SpringGraph
7 |
8 | /**
9 | * A graph drawing utility.
10 | */
11 | object ImageRenderer extends GraphRenderer {
12 |
13 | def drawGraph(graph: SpringGraph, width: Int, height: Int, showLabels: Boolean = false) =
14 | draw(graph, width, height, showLabels, None, None)
15 |
16 | def drawGraph(graph: SpringGraph, width: Int, height: Int, showLabels: Boolean, nodePainter: (Seq[Node2D], Graphics2D) => Unit) =
17 | draw(graph, width, height, showLabels, Some(nodePainter), None)
18 |
19 | def drawGraph(graph: SpringGraph, width: Int, height: Int, showLabels: Boolean, nodePainter: (Seq[Node2D], Graphics2D) => Unit, edgePainter: (Seq[Edge2D], Graphics2D) => Unit) =
20 | draw(graph, width, height, showLabels, Some(nodePainter), Some(edgePainter))
21 |
22 | private def draw(graph: SpringGraph, width: Int, height: Int, showLabels: Boolean,
23 | nodePainter: Option[(Seq[Node2D], Graphics2D) => Unit] = None,
24 | edgePainter: Option[(Seq[Edge2D], Graphics2D) => Unit]): BufferedImage = {
25 |
26 | val image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)
27 | val g = image.getGraphics.asInstanceOf[Graphics2D]
28 | g.setRenderingHint(
29 | RenderingHints.KEY_ANTIALIASING,
30 | RenderingHints.VALUE_ANTIALIAS_ON)
31 | g.setRenderingHint(
32 | RenderingHints.KEY_FRACTIONALMETRICS,
33 | RenderingHints.VALUE_FRACTIONALMETRICS_ON)
34 |
35 | if (nodePainter.isDefined)
36 | setNodePainter(nodePainter.get)
37 |
38 | if (edgePainter.isDefined)
39 | setEdgePainter(edgePainter.get)
40 |
41 | render(g, graph, width, height)
42 |
43 | image
44 | }
45 |
46 | }
--------------------------------------------------------------------------------
/src/main/scala/at/ait/dme/forcelayout/renderer/OpenGLInteractiveGraphRenderer.scala:
--------------------------------------------------------------------------------
1 | package at.ait.dme.forcelayout.renderer
2 |
3 | import java.awt.{ Canvas, Graphics2D, Point, RenderingHints }
4 | import at.ait.dme.forcelayout.{ Node, SpringGraph }
5 | import java.awt.image.BufferStrategy
6 | import java.awt.event.{ MouseAdapter, MouseEvent, MouseWheelListener, MouseWheelEvent }
7 |
8 | class OpenGLInteractiveGraphRenderer(graph: SpringGraph) extends Canvas with GraphRenderer {
9 |
10 | System.setProperty("sun.java2d.opengl", "True")
11 | System.setProperty("sun.java2d.ddscale", "True")
12 | System.setProperty("sun.java2d.translaccel", "True")
13 |
14 | private var currentZoom = 1.0
15 | private var currentXOffset = 0.0
16 | private var currentYOffset = 0.0
17 | private var lastMousePos = new Point(0, 0)
18 |
19 | private var selectedNode: Option[Node] = None
20 |
21 | private var strategy: BufferStrategy = null
22 |
23 | addMouseMotionListener(new MouseAdapter() {
24 | override def mouseDragged(e: MouseEvent) {
25 | currentXOffset += e.getX - lastMousePos.getX
26 | currentYOffset += e.getY - lastMousePos.getY
27 | lastMousePos = e.getPoint
28 | doPaint(strategy)
29 | }
30 | })
31 |
32 | addMouseListener(new MouseAdapter() {
33 | override def mouseClicked(e: MouseEvent) {
34 | val size = getSize()
35 | val coords = toGraphCoords(graph, e.getPoint, size.getWidth.toInt, size.getHeight.toInt, currentXOffset, currentYOffset, currentZoom)
36 | selectedNode = Some(graph.getNearestNode(coords))
37 | doPaint(strategy)
38 | }
39 |
40 | override def mousePressed(e: MouseEvent) = lastMousePos = e.getPoint
41 | })
42 |
43 | addMouseWheelListener(new MouseWheelListener() {
44 | override def mouseWheelMoved(e: MouseWheelEvent) {
45 | // TODO make zooming sensitive to mouse position
46 | if (e.getWheelRotation() > 0)
47 | currentZoom /= 1.1
48 | else
49 | currentZoom *= 1.1
50 |
51 | doPaint(strategy)
52 | }
53 | })
54 |
55 | def start = {
56 | createBufferStrategy(2)
57 | strategy = getBufferStrategy
58 | graph.doLayout(onComplete = (it => { println("completed in " + it + " iterations"); doPaint(strategy) }),
59 | onIteration = (it => doPaint(strategy)))
60 | }
61 |
62 | def doPaint(strategy: BufferStrategy): Unit = {
63 | val g2d = strategy.getDrawGraphics.asInstanceOf[Graphics2D]
64 | g2d.setRenderingHint(
65 | RenderingHints.KEY_ANTIALIASING,
66 | RenderingHints.VALUE_ANTIALIAS_ON)
67 | g2d.setRenderingHint(
68 | RenderingHints.KEY_FRACTIONALMETRICS,
69 | RenderingHints.VALUE_FRACTIONALMETRICS_ON)
70 |
71 | val bounds = getSize
72 | render(g2d, graph, bounds.getWidth.toInt, bounds.getHeight.toInt, selectedNode, currentXOffset, currentYOffset, currentZoom)
73 | g2d.dispose
74 | strategy.show
75 | }
76 |
77 | }
78 |
79 |
--------------------------------------------------------------------------------
/src/test/resources/.gitignore:
--------------------------------------------------------------------------------
1 | /sample-large.warc.gz
2 |
--------------------------------------------------------------------------------
/src/test/resources/examples/1996-uk-web-hosts.tsv.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rsimon/scala-force-layout/d297da7218b8da2bb4cb659e7b9c71d44c4caff5/src/test/resources/examples/1996-uk-web-hosts.tsv.gz
--------------------------------------------------------------------------------
/src/test/resources/examples/miserables.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodes":[
3 | {"name":"Myriel","group":1},
4 | {"name":"Napoleon","group":1},
5 | {"name":"Mlle.Baptistine","group":1},
6 | {"name":"Mme.Magloire","group":1},
7 | {"name":"CountessdeLo","group":1},
8 | {"name":"Geborand","group":1},
9 | {"name":"Champtercier","group":1},
10 | {"name":"Cravatte","group":1},
11 | {"name":"Count","group":1},
12 | {"name":"OldMan","group":1},
13 | {"name":"Labarre","group":2},
14 | {"name":"Valjean","group":2},
15 | {"name":"Marguerite","group":3},
16 | {"name":"Mme.deR","group":2},
17 | {"name":"Isabeau","group":2},
18 | {"name":"Gervais","group":2},
19 | {"name":"Tholomyes","group":3},
20 | {"name":"Listolier","group":3},
21 | {"name":"Fameuil","group":3},
22 | {"name":"Blacheville","group":3},
23 | {"name":"Favourite","group":3},
24 | {"name":"Dahlia","group":3},
25 | {"name":"Zephine","group":3},
26 | {"name":"Fantine","group":3},
27 | {"name":"Mme.Thenardier","group":4},
28 | {"name":"Thenardier","group":4},
29 | {"name":"Cosette","group":5},
30 | {"name":"Javert","group":4},
31 | {"name":"Fauchelevent","group":0},
32 | {"name":"Bamatabois","group":2},
33 | {"name":"Perpetue","group":3},
34 | {"name":"Simplice","group":2},
35 | {"name":"Scaufflaire","group":2},
36 | {"name":"Woman1","group":2},
37 | {"name":"Judge","group":2},
38 | {"name":"Champmathieu","group":2},
39 | {"name":"Brevet","group":2},
40 | {"name":"Chenildieu","group":2},
41 | {"name":"Cochepaille","group":2},
42 | {"name":"Pontmercy","group":4},
43 | {"name":"Boulatruelle","group":6},
44 | {"name":"Eponine","group":4},
45 | {"name":"Anzelma","group":4},
46 | {"name":"Woman2","group":5},
47 | {"name":"MotherInnocent","group":0},
48 | {"name":"Gribier","group":0},
49 | {"name":"Jondrette","group":7},
50 | {"name":"Mme.Burgon","group":7},
51 | {"name":"Gavroche","group":8},
52 | {"name":"Gillenormand","group":5},
53 | {"name":"Magnon","group":5},
54 | {"name":"Mlle.Gillenormand","group":5},
55 | {"name":"Mme.Pontmercy","group":5},
56 | {"name":"Mlle.Vaubois","group":5},
57 | {"name":"Lt.Gillenormand","group":5},
58 | {"name":"Marius","group":8},
59 | {"name":"BaronessT","group":5},
60 | {"name":"Mabeuf","group":8},
61 | {"name":"Enjolras","group":8},
62 | {"name":"Combeferre","group":8},
63 | {"name":"Prouvaire","group":8},
64 | {"name":"Feuilly","group":8},
65 | {"name":"Courfeyrac","group":8},
66 | {"name":"Bahorel","group":8},
67 | {"name":"Bossuet","group":8},
68 | {"name":"Joly","group":8},
69 | {"name":"Grantaire","group":8},
70 | {"name":"MotherPlutarch","group":9},
71 | {"name":"Gueulemer","group":4},
72 | {"name":"Babet","group":4},
73 | {"name":"Claquesous","group":4},
74 | {"name":"Montparnasse","group":4},
75 | {"name":"Toussaint","group":5},
76 | {"name":"Child1","group":10},
77 | {"name":"Child2","group":10},
78 | {"name":"Brujon","group":4},
79 | {"name":"Mme.Hucheloup","group":8}
80 | ],
81 | "links":[
82 | {"source":1,"target":0,"value":1},
83 | {"source":2,"target":0,"value":8},
84 | {"source":3,"target":0,"value":10},
85 | {"source":3,"target":2,"value":6},
86 | {"source":4,"target":0,"value":1},
87 | {"source":5,"target":0,"value":1},
88 | {"source":6,"target":0,"value":1},
89 | {"source":7,"target":0,"value":1},
90 | {"source":8,"target":0,"value":2},
91 | {"source":9,"target":0,"value":1},
92 | {"source":11,"target":10,"value":1},
93 | {"source":11,"target":3,"value":3},
94 | {"source":11,"target":2,"value":3},
95 | {"source":11,"target":0,"value":5},
96 | {"source":12,"target":11,"value":1},
97 | {"source":13,"target":11,"value":1},
98 | {"source":14,"target":11,"value":1},
99 | {"source":15,"target":11,"value":1},
100 | {"source":17,"target":16,"value":4},
101 | {"source":18,"target":16,"value":4},
102 | {"source":18,"target":17,"value":4},
103 | {"source":19,"target":16,"value":4},
104 | {"source":19,"target":17,"value":4},
105 | {"source":19,"target":18,"value":4},
106 | {"source":20,"target":16,"value":3},
107 | {"source":20,"target":17,"value":3},
108 | {"source":20,"target":18,"value":3},
109 | {"source":20,"target":19,"value":4},
110 | {"source":21,"target":16,"value":3},
111 | {"source":21,"target":17,"value":3},
112 | {"source":21,"target":18,"value":3},
113 | {"source":21,"target":19,"value":3},
114 | {"source":21,"target":20,"value":5},
115 | {"source":22,"target":16,"value":3},
116 | {"source":22,"target":17,"value":3},
117 | {"source":22,"target":18,"value":3},
118 | {"source":22,"target":19,"value":3},
119 | {"source":22,"target":20,"value":4},
120 | {"source":22,"target":21,"value":4},
121 | {"source":23,"target":16,"value":3},
122 | {"source":23,"target":17,"value":3},
123 | {"source":23,"target":18,"value":3},
124 | {"source":23,"target":19,"value":3},
125 | {"source":23,"target":20,"value":4},
126 | {"source":23,"target":21,"value":4},
127 | {"source":23,"target":22,"value":4},
128 | {"source":23,"target":12,"value":2},
129 | {"source":23,"target":11,"value":9},
130 | {"source":24,"target":23,"value":2},
131 | {"source":24,"target":11,"value":7},
132 | {"source":25,"target":24,"value":13},
133 | {"source":25,"target":23,"value":1},
134 | {"source":25,"target":11,"value":12},
135 | {"source":26,"target":24,"value":4},
136 | {"source":26,"target":11,"value":31},
137 | {"source":26,"target":16,"value":1},
138 | {"source":26,"target":25,"value":1},
139 | {"source":27,"target":11,"value":17},
140 | {"source":27,"target":23,"value":5},
141 | {"source":27,"target":25,"value":5},
142 | {"source":27,"target":24,"value":1},
143 | {"source":27,"target":26,"value":1},
144 | {"source":28,"target":11,"value":8},
145 | {"source":28,"target":27,"value":1},
146 | {"source":29,"target":23,"value":1},
147 | {"source":29,"target":27,"value":1},
148 | {"source":29,"target":11,"value":2},
149 | {"source":30,"target":23,"value":1},
150 | {"source":31,"target":30,"value":2},
151 | {"source":31,"target":11,"value":3},
152 | {"source":31,"target":23,"value":2},
153 | {"source":31,"target":27,"value":1},
154 | {"source":32,"target":11,"value":1},
155 | {"source":33,"target":11,"value":2},
156 | {"source":33,"target":27,"value":1},
157 | {"source":34,"target":11,"value":3},
158 | {"source":34,"target":29,"value":2},
159 | {"source":35,"target":11,"value":3},
160 | {"source":35,"target":34,"value":3},
161 | {"source":35,"target":29,"value":2},
162 | {"source":36,"target":34,"value":2},
163 | {"source":36,"target":35,"value":2},
164 | {"source":36,"target":11,"value":2},
165 | {"source":36,"target":29,"value":1},
166 | {"source":37,"target":34,"value":2},
167 | {"source":37,"target":35,"value":2},
168 | {"source":37,"target":36,"value":2},
169 | {"source":37,"target":11,"value":2},
170 | {"source":37,"target":29,"value":1},
171 | {"source":38,"target":34,"value":2},
172 | {"source":38,"target":35,"value":2},
173 | {"source":38,"target":36,"value":2},
174 | {"source":38,"target":37,"value":2},
175 | {"source":38,"target":11,"value":2},
176 | {"source":38,"target":29,"value":1},
177 | {"source":39,"target":25,"value":1},
178 | {"source":40,"target":25,"value":1},
179 | {"source":41,"target":24,"value":2},
180 | {"source":41,"target":25,"value":3},
181 | {"source":42,"target":41,"value":2},
182 | {"source":42,"target":25,"value":2},
183 | {"source":42,"target":24,"value":1},
184 | {"source":43,"target":11,"value":3},
185 | {"source":43,"target":26,"value":1},
186 | {"source":43,"target":27,"value":1},
187 | {"source":44,"target":28,"value":3},
188 | {"source":44,"target":11,"value":1},
189 | {"source":45,"target":28,"value":2},
190 | {"source":47,"target":46,"value":1},
191 | {"source":48,"target":47,"value":2},
192 | {"source":48,"target":25,"value":1},
193 | {"source":48,"target":27,"value":1},
194 | {"source":48,"target":11,"value":1},
195 | {"source":49,"target":26,"value":3},
196 | {"source":49,"target":11,"value":2},
197 | {"source":50,"target":49,"value":1},
198 | {"source":50,"target":24,"value":1},
199 | {"source":51,"target":49,"value":9},
200 | {"source":51,"target":26,"value":2},
201 | {"source":51,"target":11,"value":2},
202 | {"source":52,"target":51,"value":1},
203 | {"source":52,"target":39,"value":1},
204 | {"source":53,"target":51,"value":1},
205 | {"source":54,"target":51,"value":2},
206 | {"source":54,"target":49,"value":1},
207 | {"source":54,"target":26,"value":1},
208 | {"source":55,"target":51,"value":6},
209 | {"source":55,"target":49,"value":12},
210 | {"source":55,"target":39,"value":1},
211 | {"source":55,"target":54,"value":1},
212 | {"source":55,"target":26,"value":21},
213 | {"source":55,"target":11,"value":19},
214 | {"source":55,"target":16,"value":1},
215 | {"source":55,"target":25,"value":2},
216 | {"source":55,"target":41,"value":5},
217 | {"source":55,"target":48,"value":4},
218 | {"source":56,"target":49,"value":1},
219 | {"source":56,"target":55,"value":1},
220 | {"source":57,"target":55,"value":1},
221 | {"source":57,"target":41,"value":1},
222 | {"source":57,"target":48,"value":1},
223 | {"source":58,"target":55,"value":7},
224 | {"source":58,"target":48,"value":7},
225 | {"source":58,"target":27,"value":6},
226 | {"source":58,"target":57,"value":1},
227 | {"source":58,"target":11,"value":4},
228 | {"source":59,"target":58,"value":15},
229 | {"source":59,"target":55,"value":5},
230 | {"source":59,"target":48,"value":6},
231 | {"source":59,"target":57,"value":2},
232 | {"source":60,"target":48,"value":1},
233 | {"source":60,"target":58,"value":4},
234 | {"source":60,"target":59,"value":2},
235 | {"source":61,"target":48,"value":2},
236 | {"source":61,"target":58,"value":6},
237 | {"source":61,"target":60,"value":2},
238 | {"source":61,"target":59,"value":5},
239 | {"source":61,"target":57,"value":1},
240 | {"source":61,"target":55,"value":1},
241 | {"source":62,"target":55,"value":9},
242 | {"source":62,"target":58,"value":17},
243 | {"source":62,"target":59,"value":13},
244 | {"source":62,"target":48,"value":7},
245 | {"source":62,"target":57,"value":2},
246 | {"source":62,"target":41,"value":1},
247 | {"source":62,"target":61,"value":6},
248 | {"source":62,"target":60,"value":3},
249 | {"source":63,"target":59,"value":5},
250 | {"source":63,"target":48,"value":5},
251 | {"source":63,"target":62,"value":6},
252 | {"source":63,"target":57,"value":2},
253 | {"source":63,"target":58,"value":4},
254 | {"source":63,"target":61,"value":3},
255 | {"source":63,"target":60,"value":2},
256 | {"source":63,"target":55,"value":1},
257 | {"source":64,"target":55,"value":5},
258 | {"source":64,"target":62,"value":12},
259 | {"source":64,"target":48,"value":5},
260 | {"source":64,"target":63,"value":4},
261 | {"source":64,"target":58,"value":10},
262 | {"source":64,"target":61,"value":6},
263 | {"source":64,"target":60,"value":2},
264 | {"source":64,"target":59,"value":9},
265 | {"source":64,"target":57,"value":1},
266 | {"source":64,"target":11,"value":1},
267 | {"source":65,"target":63,"value":5},
268 | {"source":65,"target":64,"value":7},
269 | {"source":65,"target":48,"value":3},
270 | {"source":65,"target":62,"value":5},
271 | {"source":65,"target":58,"value":5},
272 | {"source":65,"target":61,"value":5},
273 | {"source":65,"target":60,"value":2},
274 | {"source":65,"target":59,"value":5},
275 | {"source":65,"target":57,"value":1},
276 | {"source":65,"target":55,"value":2},
277 | {"source":66,"target":64,"value":3},
278 | {"source":66,"target":58,"value":3},
279 | {"source":66,"target":59,"value":1},
280 | {"source":66,"target":62,"value":2},
281 | {"source":66,"target":65,"value":2},
282 | {"source":66,"target":48,"value":1},
283 | {"source":66,"target":63,"value":1},
284 | {"source":66,"target":61,"value":1},
285 | {"source":66,"target":60,"value":1},
286 | {"source":67,"target":57,"value":3},
287 | {"source":68,"target":25,"value":5},
288 | {"source":68,"target":11,"value":1},
289 | {"source":68,"target":24,"value":1},
290 | {"source":68,"target":27,"value":1},
291 | {"source":68,"target":48,"value":1},
292 | {"source":68,"target":41,"value":1},
293 | {"source":69,"target":25,"value":6},
294 | {"source":69,"target":68,"value":6},
295 | {"source":69,"target":11,"value":1},
296 | {"source":69,"target":24,"value":1},
297 | {"source":69,"target":27,"value":2},
298 | {"source":69,"target":48,"value":1},
299 | {"source":69,"target":41,"value":1},
300 | {"source":70,"target":25,"value":4},
301 | {"source":70,"target":69,"value":4},
302 | {"source":70,"target":68,"value":4},
303 | {"source":70,"target":11,"value":1},
304 | {"source":70,"target":24,"value":1},
305 | {"source":70,"target":27,"value":1},
306 | {"source":70,"target":41,"value":1},
307 | {"source":70,"target":58,"value":1},
308 | {"source":71,"target":27,"value":1},
309 | {"source":71,"target":69,"value":2},
310 | {"source":71,"target":68,"value":2},
311 | {"source":71,"target":70,"value":2},
312 | {"source":71,"target":11,"value":1},
313 | {"source":71,"target":48,"value":1},
314 | {"source":71,"target":41,"value":1},
315 | {"source":71,"target":25,"value":1},
316 | {"source":72,"target":26,"value":2},
317 | {"source":72,"target":27,"value":1},
318 | {"source":72,"target":11,"value":1},
319 | {"source":73,"target":48,"value":2},
320 | {"source":74,"target":48,"value":2},
321 | {"source":74,"target":73,"value":3},
322 | {"source":75,"target":69,"value":3},
323 | {"source":75,"target":68,"value":3},
324 | {"source":75,"target":25,"value":3},
325 | {"source":75,"target":48,"value":1},
326 | {"source":75,"target":41,"value":1},
327 | {"source":75,"target":70,"value":1},
328 | {"source":75,"target":71,"value":1},
329 | {"source":76,"target":64,"value":1},
330 | {"source":76,"target":65,"value":1},
331 | {"source":76,"target":66,"value":1},
332 | {"source":76,"target":63,"value":1},
333 | {"source":76,"target":62,"value":1},
334 | {"source":76,"target":48,"value":1},
335 | {"source":76,"target":58,"value":1}
336 | ]
337 | }
338 |
--------------------------------------------------------------------------------