Skip to content
This repository has been archived by the owner on Feb 5, 2022. It is now read-only.

Commit

Permalink
(layout): add force directed layout (#26)
Browse files Browse the repository at this point in the history
this is basically an adaption from springy and as such
has a mutable nature, this is abstracted in the implementation
as much as possible though

closes #6

Co-authored-by: Andreas Drobisch <[email protected]>
  • Loading branch information
adrobisch and Andreas Drobisch authored Oct 31, 2021
1 parent 0a3cafd commit 69c708a
Show file tree
Hide file tree
Showing 8 changed files with 269 additions and 30 deletions.
Original file line number Diff line number Diff line change
@@ -1,27 +1,51 @@
package com.flowtick.graphs.editor.util
package com.flowtick.graphs.util

import scala.util.Random

/** line segment intersection adapted from
* https://www.codeproject.com/tips/862988/find-the-intersection-point-of-two-line-segments
*/
object MathUtil {
final case class Vector2(x: Double, y: Double) {
def -(other: Vector2): Vector2 = copy(x = x - other.x, y = y - other.y)

def +(other: Vector2): Vector2 = copy(x = x + other.x, y = y + other.y)

def *(other: Vector2): Double = x * other.x + y * other.y

def /(n: Double): Vector2 = if (n == 0.0) Vector2(0, 0) else Vector2(x / n, y / n)

def length: Double = Math.sqrt(x * x + y * y)

def normal: Vector2 = Vector2(-y, x)

def normalise: Vector2 = this / length

def times(factor: Double): Vector2 = copy(x = x * factor, y = y * factor)

def cross(other: Vector2): Double = x * other.y - y * other.x

def same(other: Vector2): Boolean =
MathUtil.isZero(x - other.x) && MathUtil.isZero(y - other.y)
}

object Vector2 {
val zero: Vector2 = Vector2(0.0, 0.0)
def random(generator: scala.util.Random = new Random()): Vector2 =
Vector2(10.0 * (generator.nextDouble() - 0.5), 10.0 * (generator.nextDouble() - 0.5))
}

final case class LineSegment(start: Vector2, end: Vector2)

final case class Rectangle(topLeft: Vector2, bottomRight: Vector2) {
def top: LineSegment = LineSegment(topLeft, topLeft.copy(x = bottomRight.x))

def left: LineSegment =
LineSegment(topLeft, topLeft.copy(y = bottomRight.y))

def bottom: LineSegment =
LineSegment(bottomRight, bottomRight.copy(x = topLeft.x))

def right: LineSegment =
LineSegment(bottomRight, bottomRight.copy(y = topLeft.y))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.flowtick.graphs

import com.flowtick.graphs.editor.util.MathUtil.{LineSegment, Vector2}
import com.flowtick.graphs.editor.util.MathUtil
import com.flowtick.graphs.util.MathUtil
import com.flowtick.graphs.util.MathUtil.{LineSegment, Vector2}
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ package com.flowtick.graphs.editor.feature

import cats.effect.IO
import com.flowtick.graphs.editor._
import com.flowtick.graphs.editor.util.MathUtil
import com.flowtick.graphs.{Edge, Node}
import com.flowtick.graphs.editor.util.MathUtil.{LineSegment, Rectangle, Vector2}
import com.flowtick.graphs.util.MathUtil.{LineSegment, Rectangle, Vector2}
import com.flowtick.graphs.layout.{EdgePath, GraphLayoutLike}
import com.flowtick.graphs.util.MathUtil

class RoutingFeature extends EditorComponent {

Expand Down
25 changes: 5 additions & 20 deletions examples/jvm/src/main/scala/ExamplesJvm.scala
Original file line number Diff line number Diff line change
@@ -1,19 +1,8 @@
import cats.effect.IO
import com.flowtick.graphs.editor.view.{SVGRendererJvm, SVGRendererOptions, SVGTranscoder}
import com.flowtick.graphs.layout.{ELkLayoutJVM, GraphLayoutOps}
import com.flowtick.graphs.layout.{ForceDirectedLayout, GraphLayoutOps}
import com.flowtick.graphs.style._
import examples.{
BfsExample,
CatsExample,
CustomGraphExample,
DfsExample,
DijkstraExample,
GraphMLExample,
JsonExample,
LayoutExample,
SimpleGraphExample,
TopologicalSortingExample
}
import examples._

import java.io.FileOutputStream

Expand All @@ -34,7 +23,7 @@ object LayoutExampleApp extends LayoutExample with App {

implicit val contextShift =
IO.contextShift(scala.concurrent.ExecutionContext.Implicits.global)
override def layoutOps: GraphLayoutOps = ELkLayoutJVM
override def layoutOps: GraphLayoutOps = ForceDirectedLayout

def writeToFile(path: String, content: Array[Byte]): IO[Unit] = IO {
val out = new FileOutputStream(path)
Expand Down Expand Up @@ -82,12 +71,8 @@ object LayoutExampleApp extends LayoutExample with App {
"target/layout_example.svg",
renderer.toXmlString.get.getBytes("UTF-8")
)
_ <- writeToFile(
"target/layout_example.png",
SVGTranscoder
.svgXmlToPng(renderer.toXmlString.get, None, None)
.unsafeRunSync()
)
pngData <- SVGTranscoder.svgXmlToPng(renderer.toXmlString.get, None, None)
_ <- writeToFile("target/layout_example.png", pngData)
} yield ()

renderImages.unsafeRunSync()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ package com.flowtick.graphs.layout
import com.flowtick.graphs.Graph
import com.flowtick.graphs.defaults._
import com.flowtick.graphs.defaults.label._

import org.scalatest.concurrent.ScalaFutures
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class ELkLayoutJVMSpec extends AnyFlatSpec with Matchers {
class ELkLayoutJVMSpec extends AnyFlatSpec with Matchers with ScalaFutures {
"Graph layout" should "layout simple graph" in {
val graph =
Graph.fromEdges[Unit, String](Set("A" --> "B", "B" --> "C", "D" --> "A"))
println(ELkLayoutJVM.layout(graph))
println(ELkLayoutJVM.layout(graph).futureValue)
}

it should "layout city graph" in {
Expand All @@ -30,6 +30,6 @@ class ELkLayoutJVMSpec extends AnyFlatSpec with Matchers {
"Augsburg" --> (84, "Muenchen")
)
)
println(ELkLayoutJVM.layout(cities))
ELkLayoutJVM.layout(cities).futureValue
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.flowtick.graphs.layout

import com.flowtick.graphs.Graph
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import com.flowtick.graphs.defaults._
import com.flowtick.graphs.defaults.label._
import org.scalatest.concurrent.ScalaFutures

class ForceDirectedLayoutJVMSpec extends AnyFlatSpec with Matchers with ScalaFutures {
"Force Directed layout" should "layout plants graph" in {
val plants = Graph.fromEdges[Unit, String](
Set(
"Norway Spruce" --> "Sicilian Fir",
"Sicilian Fir" --> "Sumatran Pine",
"Sicilian Fir" --> "Japanese Larch",
"Norway Spruce" --> "Japanese Larch",
"Norway Spruce" --> "Giant Sequoia"
)
)

val config = GraphLayoutConfiguration(seed = Some(42))
ForceDirectedLayout.layout(plants, config).futureValue should be(
GraphLayout(
nodes = Map(
"Sumatran Pine" -> DefaultGeometry(29.0, 178.0, 80.0, 40.0),
"Giant Sequoia" -> DefaultGeometry(198.0, 85.0, 80.0, 40.0),
"Sicilian Fir" -> DefaultGeometry(118.0, 0.0, 80.0, 40.0),
"Norway Spruce" -> DefaultGeometry(170.0, 204.0, 80.0, 40.0),
"Japanese Larch" -> DefaultGeometry(0.0, 34.0, 80.0, 40.0)
),
edges = Map()
)
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package com.flowtick.graphs.layout
import com.flowtick.graphs.util.MathUtil.Vector2
import com.flowtick.graphs.{Edge, Graph, Labeled, Node}

import scala.collection.mutable
import scala.concurrent.Future
import scala.util.Random

final case class Point(
position: Vector2,
mass: Double,
velocity: Vector2 = Vector2.zero,
acceleration: Vector2 = Vector2.zero
) {
def applyForce(force: Vector2): Point = copy(acceleration = acceleration + (force / mass))
}

final case class Spring(start: Point, end: Point, length: Double, stiffness: Double) {
def applyForce(amount: Double): Spring = {
val d = end.position.-(start.position) // the direction of the theSpring
val displacement = length - d.length
val direction = d.normalise

copy(
start = start.applyForce(direction.times(stiffness * displacement * -amount)),
end = end.applyForce(direction.times(stiffness * displacement * amount))
)
}
}

// adapted from https://github.com/dhotson/springy/blob/master/springy.js
class ForceDirectedLayout private (
stiffness: Double = 400.0,
repulsion: Double = 400.0,
damping: Double = 0.5,
minEnergyThreshold: Double = 0.01,
maxSpeed: Double = Double.PositiveInfinity,
seed: Option[Long] = None,
maxTicks: Int = 1000
) {
private val nodePoints: mutable.Map[String, Point] = mutable.Map()
private val edgeSprings: mutable.Map[String, Spring] = mutable.Map()
private val random = seed.fold(new Random())(new Random(_))

private def point(nodeId: String): Point =
nodePoints.get(nodeId) match {
case Some(existing) => existing
case None =>
val point = Point(position = Vector2.random(random), 1.0)
nodePoints += nodeId -> point
point
}

private def findOutgoingSpring[E, N](g: Graph[E, N], nodeId: String): Option[Spring] =
g
.outgoing(nodeId)
.map(edge => edgeSprings.get(edge.id))
.filter(_.isDefined)
.lastOption
.flatten

private def spring[E, N](g: Graph[E, N], edge: Edge[E]): Spring =
edgeSprings.get(edge.id) match {
case Some(existing) => existing
case None =>
val length = 1.0
val connectedSpring =
findOutgoingSpring(g, edge.from).orElse(findOutgoingSpring(g, edge.to))

connectedSpring.getOrElse {
val newSpring = Spring(
point(edge.from),
point(edge.to),
length,
stiffness
)
edgeSprings += edge.id -> newSpring
newSpring
}
}

private def eachNode[E, N, T](g: Graph[E, N])(f: (Node[N], Point) => T): Iterable[T] =
g.nodes.map { node =>
f(node, point(node.id))
}

private def eachSpring[E, N](g: Graph[E, N])(f: Spring => Spring): Unit =
g.edges.map { edge =>
f(spring(g, edge))
}

def applyCoulombsLaw[E, N](g: Graph[E, N]): Iterable[Iterable[Any]] =
eachNode(g) { case (node1, point1) =>
eachNode(g) { case (node2, point2) =>
if (point1 != point2) {
val d = point1.position - point2.position
val distance =
d.length + 0.1 // avoid massive forces at small distances (and divide by zero)
val direction = d.normalise

// apply force to each end point
nodePoints += node1.id -> point1.applyForce(
direction.times(repulsion)./(distance * distance * 0.5)
)
nodePoints += node2.id -> point2.applyForce(
direction.times(repulsion)./(distance * distance * -0.5)
)
}
}
}

def applyHookesLaw[E, N](g: Graph[E, N]): Unit = eachSpring(g)(_.applyForce(0.5))

def attractToCentre[E, N](g: Graph[E, N]): Unit =
eachNode(g) { case (node, point) =>
val direction = point.position.times(-1.0)
nodePoints += node.id -> point.applyForce(direction.times(repulsion / 50.0))
}

def updateVelocity[E, N](g: Graph[E, N])(timestep: Double): Unit =
eachNode(g) { case (node, point) =>
val velocity = point.velocity.+(point.acceleration.times(timestep)).times(damping)

val withMaximum = if (velocity.length > maxSpeed) {
point.velocity.normalise.times(maxSpeed)
} else velocity

nodePoints += node.id -> point.copy(velocity = withMaximum, acceleration = Vector2.zero)
}

def updatePosition[E, N](g: Graph[E, N])(timestep: Double): Unit =
eachNode(g) { case (node, point) =>
nodePoints += node.id -> point.copy(position =
point.position.+(point.velocity.times(timestep))
)
}

def totalEnergy[E, N](g: Graph[E, N]): Double =
eachNode(g) { case (_, point) =>
val speed = point.velocity.length
0.5 * point.mass * speed * speed
}.sum

def tick[E, N](g: Graph[E, N])(timestep: Double): ForceDirectedLayout = {
applyCoulombsLaw(g)
applyHookesLaw(g)
attractToCentre(g)
updateVelocity(g)(timestep)
updatePosition(g)(timestep)
this
}

def toLayout[E, N](
g: Graph[E, N],
layoutConfiguration: GraphLayoutConfiguration,
stepSize: Double = 0.03
): GraphLayout = {
val (layout, _) = (1 to maxTicks).view
.map(step => (tick(g)(stepSize), step))
.find {
case (layout, _) if layout.totalEnergy(g) < minEnergyThreshold => true
case _ => false
}
.getOrElse((this, maxTicks))

val scale = layoutConfiguration.scale.getOrElse(20.0)
val (minX, minY) = layout.nodePoints.values.foldLeft((0.0, 0.0)) { case ((x, y), point) =>
(Math.min(x, point.position.x), Math.min(y, point.position.y))
}

val nodeGeometry = layout.nodePoints.view.map { case (key, point) =>
key -> DefaultGeometry(
Math.round((point.position.x + Math.abs(minX)) * scale),
Math.round((point.position.y + Math.abs(minY)) * scale),
layoutConfiguration.nodeWidth,
layoutConfiguration.nodeHeight
)
}.toMap

GraphLayout(nodeGeometry)
}

}

object ForceDirectedLayout extends GraphLayoutOps {
override def layout[E, N](g: Graph[E, N], layoutConfiguration: GraphLayoutConfiguration)(implicit
edgeLabel: Labeled[Edge[E], String]
): Future[GraphLayoutLike] =
Future.successful(
new ForceDirectedLayout(seed = layoutConfiguration.seed).toLayout(g, layoutConfiguration)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,10 +152,12 @@ object LayoutType {
final case class GraphLayoutConfiguration(
nodeWidth: Double = 80,
nodeHeight: Double = 40,
scale: Option[Double] = None,
spacing: Option[Double] = None,
spacingNodeNode: Option[Double] = None,
direction: Option[LayoutDirection] = None,
layoutType: Option[LayoutType] = None
layoutType: Option[LayoutType] = None,
seed: Option[Long] = None
)

trait GraphLayoutOps {
Expand Down

0 comments on commit 69c708a

Please sign in to comment.