├── .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 | ![Scala Force Layout Example](http://github.com/rsimon/scala-force-layout/raw/master/scala-force-layout.png) 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 | --------------------------------------------------------------------------------