Compare commits
7 Commits
99d64242cf
...
1.0-beta
Author | SHA1 | Date | |
---|---|---|---|
b00dc96f5b
|
|||
6f0f5d05b6
|
|||
9f78c3e44a
|
|||
2d36d60020
|
|||
d460c71a33
|
|||
4a5db84148
|
|||
64560b172b
|
@ -1,15 +1,19 @@
|
||||
plugins {
|
||||
kotlin("jvm") version "2.2.0"
|
||||
id("org.jetbrains.dokka") version "2.0.0"
|
||||
`java-library`
|
||||
`maven-publish`
|
||||
}
|
||||
|
||||
group = "be.topl.phoenix-intellij"
|
||||
version = "1.0-SNAPSHOT"
|
||||
group = "com.github.jaytux"
|
||||
version = "1.0-alpha"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(kotlin("stdlib"))
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
@ -19,3 +23,22 @@ tasks.test {
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
tasks.register<Jar>("dokkaJavadocJar") {
|
||||
dependsOn(tasks.dokkaJavadoc)
|
||||
from(tasks.dokkaJavadoc.flatMap { it.outputDirectory })
|
||||
archiveClassifier.set("javadoc")
|
||||
}
|
||||
|
||||
java {
|
||||
withSourcesJar()
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
create<MavenPublication>("maven") {
|
||||
from(components["java"])
|
||||
artifact(tasks.named("dokkaJavadocJar"))
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,19 @@
|
||||
package com.jaytux.altgraph.core
|
||||
|
||||
/**
|
||||
* A mutable directed graph implementation, with arbitrary vertex and edge types. Supports marking vertices as "roots"
|
||||
* during addition.
|
||||
*
|
||||
* Vertices and edges must be unique (no duplicates allowed). Edges are directed, and there can be at most one edge
|
||||
* from a given vertex `A` to another vertex `B`.
|
||||
*
|
||||
* All operations that attempt to violate these constraints will throw a [GraphException].
|
||||
*
|
||||
* @param V The vertex type.
|
||||
* @param E The edge type.
|
||||
* @see [IMutableGraph]
|
||||
* @see [IGraph]
|
||||
*/
|
||||
open class BaseGraph<V, E> : IMutableGraph<V, E> {
|
||||
private val _vertices = mutableMapOf<V, Boolean>()
|
||||
// [from] -> {e: exists v s.t. e = (from, v)}
|
||||
@ -70,6 +84,15 @@ open class BaseGraph<V, E> : IMutableGraph<V, E> {
|
||||
if (_existing[from]?.isEmpty() == true) { _existing.remove(from) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an immutable view of this graph.
|
||||
*
|
||||
* Further modifications to the original graph will be reflected in the immutable view.
|
||||
*
|
||||
* This operations is very cheap, as it does not involve any copying of data.
|
||||
*
|
||||
* @return An immutable view of this graph
|
||||
*/
|
||||
fun immutable(): IGraph<V, E> = BaseGraphImmutable()
|
||||
|
||||
override fun successors(vertex: V): Map<V, E> =
|
||||
|
@ -1,42 +1,221 @@
|
||||
package com.jaytux.altgraph.core
|
||||
|
||||
/**
|
||||
* A simple, immutable graph interface.
|
||||
*
|
||||
* The graph is directed, and may contain cycles (including self-loops). However, it may not contain parallel edges.
|
||||
*
|
||||
* The supported operations are:
|
||||
* - Querying for vertices and edges;
|
||||
* - Querying for graph roots (these do not have to be roots in the traditional sense, but can be any vertex that
|
||||
* should be treated as a root for layout purposes);
|
||||
* - Querying for successors and predecessors of a vertex;
|
||||
* - Querying for the edge between two vertices, if it exists.
|
||||
*
|
||||
* Importantly, each vertex (`V`) and edge (`E`) should be unique in the graph. Edges should not encode any incidence
|
||||
* information (i.e. their endpoints), but should be distinct objects.
|
||||
*
|
||||
* Mutable graphs should implement [IMutableGraph], which extends this interface with mutation operations.
|
||||
*
|
||||
* @param V the type of vertices in the graph
|
||||
* @param E the type of edges in the graph
|
||||
* @see IMutableGraph
|
||||
* @see BaseGraph
|
||||
*/
|
||||
interface IGraph<V, E> {
|
||||
/**
|
||||
* Gets all the vertices in the graph.
|
||||
*
|
||||
* @return a set of all vertices in the graph
|
||||
*/
|
||||
fun vertices(): Set<V>
|
||||
|
||||
/**
|
||||
* Gets all the edges in the graph, along with their endpoints.
|
||||
*
|
||||
* @return a map from edges to their endpoints (as pairs (from, to))
|
||||
*/
|
||||
fun edges(): Map<E, Pair<V, V>>
|
||||
|
||||
/**
|
||||
* Gets all the root vertices in the graph.
|
||||
*
|
||||
* @return a set of all root vertices in the graph
|
||||
*/
|
||||
fun roots(): Set<V>
|
||||
|
||||
/**
|
||||
* Gets the successors of a given vertex, along with the connecting edges.
|
||||
*
|
||||
* A default implementation is provided in the [IGraph] interface, but may be overridden for efficiency.
|
||||
*
|
||||
* @param vertex the vertex whose successors are to be found
|
||||
* @return a map from successor vertices to their outgoing edges
|
||||
* @see predecessors
|
||||
*/
|
||||
fun successors(vertex: V): Map<V, E> =
|
||||
edges().filter { it.value.first == vertex }
|
||||
.map { (edge, pair) -> pair.second to edge }.toMap()
|
||||
|
||||
/**
|
||||
* Gets the predecessors of a given vertex, along with the connecting edges.
|
||||
*
|
||||
* A default implementation is provided in the [IGraph] interface, but may be overridden for efficiency.
|
||||
*
|
||||
* @param vertex the vertex whose predecessors are to be found
|
||||
* @return a map from predecessor vertices to their incoming edges
|
||||
* @see successors
|
||||
*/
|
||||
fun predecessors(vertex: V): Map<V, E> =
|
||||
edges().filter { it.value.second == vertex }
|
||||
.map { (edge, pair) -> pair.first to edge }.toMap()
|
||||
|
||||
/**
|
||||
* Checks whether an edge exists between two vertices, and returns it if so.
|
||||
*
|
||||
* A default implementation is provided in the [IGraph] interface, but may be overridden for efficiency.
|
||||
*
|
||||
* @param x the starting vertex
|
||||
* @param y the ending vertex
|
||||
* @return the edge from `x` to `y`, or `null` if no such edge exists
|
||||
*/
|
||||
fun xToY(x: V, y: V): E? = edges().entries.firstOrNull { it.value == x to y }?.key
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple, mutable graph interface, extending [IGraph].
|
||||
*
|
||||
* In addition to the operations provided by [IGraph], mutable graphs support:
|
||||
* - Adding and removing vertices;
|
||||
* - Connecting and disconnecting vertices with edges.
|
||||
*
|
||||
* The same restrictions apply as for [IGraph]: the graph is directed, can contain cycles (including self-loops), can't
|
||||
* contain parallel edges, and vertex (`V`) and edge (`E`) objects should be unique in the graph.
|
||||
*
|
||||
* Immutable graphs should implement [IGraph].
|
||||
*
|
||||
* @param V the type of vertices in the graph
|
||||
* @param E the type of edges in the graph
|
||||
* @see IGraph
|
||||
* @see BaseGraph
|
||||
*/
|
||||
interface IMutableGraph<V, E> : IGraph<V, E> {
|
||||
/**
|
||||
* Adds a vertex to the graph.
|
||||
*
|
||||
* @param vertex the vertex to add
|
||||
* @param isRoot whether the vertex should be considered a root (by default, `false`)
|
||||
* @return the added vertex
|
||||
* @throws GraphException if the vertex already exists in the graph
|
||||
*/
|
||||
fun addVertex(vertex: V, isRoot: Boolean = false): V
|
||||
|
||||
/**
|
||||
* Removes a vertex from the graph.
|
||||
*
|
||||
* If the vertex has any incident edges (i.e. incoming or outgoing edges), they are also removed.
|
||||
*
|
||||
* @param vertex the vertex to remove
|
||||
* @throws GraphException if the vertex does not exist in the graph
|
||||
*/
|
||||
fun removeVertex(vertex: V)
|
||||
|
||||
/**
|
||||
* Connects two vertices with an edge.
|
||||
*
|
||||
* @param from the starting vertex
|
||||
* @param to the ending vertex
|
||||
* @param edge the edge to add between `from` and `to`
|
||||
* @return the added edge
|
||||
* @throws GraphException if either vertex does not exist in the graph, if the edge already exists in the graph,
|
||||
* or if there is already an edge between `from` and `to`.
|
||||
*/
|
||||
fun connect(from: V, to: V, edge: E): E
|
||||
|
||||
/**
|
||||
* Disconnects two vertices by removing the edge between them.
|
||||
*
|
||||
* @param from the starting vertex
|
||||
* @param to the ending vertex
|
||||
* @throws GraphException if there is no edge between `from` and `to`
|
||||
*/
|
||||
fun disconnect(from: V, to: V)
|
||||
|
||||
/**
|
||||
* Removes an edge from the graph.
|
||||
*
|
||||
* @param edge the edge to remove
|
||||
* @throws GraphException if the edge does not exist in the graph
|
||||
*/
|
||||
fun removeEdge(edge: E)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception thrown when a graph operation fails.
|
||||
*
|
||||
* @param message the exception message
|
||||
*/
|
||||
class GraphException(message: String) : RuntimeException(message) {
|
||||
companion object {
|
||||
/**
|
||||
* Constructs a [GraphException] indicating that a vertex already exists in the graph.
|
||||
*
|
||||
* @param V the type of vertices in the graph
|
||||
* @param vertex the vertex that already exists
|
||||
* @return a [GraphException] with an appropriate message
|
||||
*/
|
||||
fun <V> vertexAlreadyExists(vertex: V) =
|
||||
GraphException("Vertex '$vertex' already exists in this graph.")
|
||||
|
||||
/**
|
||||
* Constructs a [GraphException] indicating that an edge already exists in the graph.
|
||||
*
|
||||
* @param E the type of edges in the graph
|
||||
* @param edge the edge that already exists
|
||||
* @return a [GraphException] with an appropriate message
|
||||
*/
|
||||
fun <E> edgeAlreadyExists(edge: E) =
|
||||
GraphException("Edge '$edge' already exists in this graph.")
|
||||
|
||||
/**
|
||||
* Constructs a [GraphException] indicating that an edge already exists between two vertices in the graph.
|
||||
*
|
||||
* @param V the type of vertices in the graph
|
||||
* @param from the starting vertex
|
||||
* @param to the ending vertex
|
||||
* @return a [GraphException] with an appropriate message
|
||||
*/
|
||||
fun <V> edgeBetweenAlreadyExists(from: V, to: V) =
|
||||
GraphException("Edge from '$from' to '$to' already exists in this graph.")
|
||||
|
||||
/**
|
||||
* Constructs a [GraphException] indicating that a vertex was not found in the graph.
|
||||
*
|
||||
* @param V the type of vertices in the graph
|
||||
* @param vertex the vertex that was not found
|
||||
* @return a [GraphException] with an appropriate message
|
||||
*/
|
||||
fun <V> vertexNotFound(vertex: V) =
|
||||
GraphException("Vertex '$vertex' not found in this graph.")
|
||||
|
||||
/**
|
||||
* Constructs a [GraphException] indicating that no edge was found between two vertices in the graph.
|
||||
*
|
||||
* @param V the type of vertices in the graph
|
||||
* @param from the starting vertex
|
||||
* @param to the ending vertex
|
||||
* @return a [GraphException] with an appropriate message
|
||||
*/
|
||||
fun <V> noEdgeFound(from: V, to: V) =
|
||||
GraphException("No edge found from '$from' to '$to' in this graph.")
|
||||
|
||||
/**
|
||||
* Constructs a [GraphException] indicating that an edge was not found in the graph.
|
||||
*
|
||||
* @param E the type of edges in the graph
|
||||
* @param edge the edge that was not found
|
||||
* @return a [GraphException] with an appropriate message
|
||||
*/
|
||||
fun <E> edgeNotFound(edge: E) =
|
||||
GraphException("Edge '$edge' not found in this graph.")
|
||||
}
|
||||
|
51
src/main/kotlin/com/jaytux/altgraph/examples/SimpleCFG.kt
Normal file
51
src/main/kotlin/com/jaytux/altgraph/examples/SimpleCFG.kt
Normal file
@ -0,0 +1,51 @@
|
||||
package com.jaytux.altgraph.examples
|
||||
|
||||
import com.jaytux.altgraph.core.BaseGraph
|
||||
import com.jaytux.altgraph.core.IGraph
|
||||
import com.jaytux.altgraph.layout.PseudoForestLayout
|
||||
import com.jaytux.altgraph.swing.DefaultVertexComponent
|
||||
import com.jaytux.altgraph.swing.GraphPane
|
||||
import javax.swing.JFrame
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
fun getGraph(): IGraph<String, Int> {
|
||||
val graph = BaseGraph<String, Int>()
|
||||
// vertices
|
||||
val entry = graph.addVertex("[ENTRY]", true)
|
||||
val exit = graph.addVertex("[EXIT]")
|
||||
val labels = intArrayOf(198, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210).associateWith { graph.addVertex(".label_$it") }
|
||||
|
||||
var count = 0
|
||||
graph.connect(entry, labels[198]!!, count++)
|
||||
arrayOf(
|
||||
198 to 200, 200 to 201, 200 to 202, 201 to 203, 201 to 204, 203 to 200, 204 to 205, 205 to 208, 208 to 209,
|
||||
209 to 200, 209 to 210, 210 to 208, 204 to 206, 206 to 207, 207 to 208
|
||||
).forEach { (from, to) -> graph.connect(labels[from]!!, labels[to]!!, count++) }
|
||||
graph.connect(labels[202]!!, exit, count++)
|
||||
return graph
|
||||
}
|
||||
|
||||
fun getPane(graph: IGraph<String, Int>): GraphPane<String, Int> {
|
||||
val pane = GraphPane(graph) { pane, graph ->
|
||||
PseudoForestLayout(graph, 10.0f, 20.0f, 10.0f) { v ->
|
||||
(pane.getComponentFor(v) as DefaultVertexComponent).vertexSize()
|
||||
}
|
||||
}
|
||||
return pane
|
||||
}
|
||||
|
||||
fun main() {
|
||||
val graph = getGraph()
|
||||
val pane = getPane(graph)
|
||||
|
||||
// show pane in window
|
||||
SwingUtilities.invokeLater {
|
||||
val frame = JFrame("Simple Control Flow Graph")
|
||||
frame.defaultCloseOperation = JFrame.EXIT_ON_CLOSE
|
||||
frame.setSize(800, 600)
|
||||
frame.add(pane)
|
||||
frame.pack()
|
||||
frame.setLocationRelativeTo(null)
|
||||
frame.isVisible = true
|
||||
}
|
||||
}
|
65
src/main/kotlin/com/jaytux/altgraph/examples/SimpleRegDep.kt
Normal file
65
src/main/kotlin/com/jaytux/altgraph/examples/SimpleRegDep.kt
Normal file
@ -0,0 +1,65 @@
|
||||
package com.jaytux.altgraph.examples
|
||||
|
||||
import com.jaytux.altgraph.core.BaseGraph
|
||||
import com.jaytux.altgraph.core.IGraph
|
||||
import com.jaytux.altgraph.layout.PseudoForestLayout
|
||||
import com.jaytux.altgraph.swing.DefaultVertexComponent
|
||||
import com.jaytux.altgraph.swing.GraphPane
|
||||
import com.jaytux.altgraph.swing.QuadraticEdge
|
||||
import java.awt.Color
|
||||
import javax.swing.JFrame
|
||||
import javax.swing.SwingUtilities
|
||||
|
||||
data class Edge(val id: Int, val isWrite: Boolean)
|
||||
fun getRegGraph(): IGraph<String, Edge> {
|
||||
val graph = BaseGraph<String, Edge>()
|
||||
val allowed = setOf(633, 631, 632, 634, 638, 635, 636)
|
||||
val roots = listOf(647, 645, 643, 530, 519, 512, 513, 522, 523, 631, 632, 633, 655).sorted().filter { it in allowed }
|
||||
val others = listOf(533, 550, 547, 552, 637, 638, 635, 634, 548, 659, 657, 636, 639, 557, 642, 640, 644, 646, 648, 650, 652, 653, 654, 656, 658).sorted().filter { it in allowed }
|
||||
|
||||
val regs = roots.associateWith { graph.addVertex("%reg$it", true) } + others.associateWith { graph.addVertex("%reg$it", false) }
|
||||
|
||||
var count = 0
|
||||
val normal = listOf(
|
||||
647 to 648, 645 to 646, 519 to 533, 519 to 557, 519 to 639, 512 to 550, 512 to 547, 513 to 552, 522 to 659, 522 to 657, 523 to 637, 631 to 638, 631 to 635,
|
||||
632 to 634, 633 to 634, 633 to 636, 547 to 548, 637 to 659, 637 to 557, 637 to 639, 637 to 657, 635 to 636, 639 to 642, 639 to 640, 639 to 644, 646 to 648,
|
||||
648 to 650, 639 to 648, 639 to 650, 639 to 653, 650 to 652, 650 to 652, 650 to 656, 650 to 654, 652 to 653, 654 to 656, 655 to 656, 650 to 658, 557 to 642,
|
||||
557 to 654, 644 to 646, 657 to 658
|
||||
).filter{ (f,s) -> f in allowed && s in allowed }.map { it to false }
|
||||
val writes = listOf(
|
||||
656 to 643, 550 to 519, 548 to 513, 552 to 522, 658 to 522, 637 to 522, 636 to 631, 634 to 631
|
||||
).filter{ (f,s) -> f in allowed && s in allowed }.map { it to true }
|
||||
(normal + writes).forEach { (p, isWrite) ->
|
||||
val (from, to) = p
|
||||
graph.connect(regs[from]!!, regs[to]!!, Edge(count++, isWrite))
|
||||
}
|
||||
|
||||
return graph
|
||||
}
|
||||
|
||||
fun getRegPane(graph: IGraph<String, Edge>): GraphPane<String, Edge> = GraphPane(graph) { pane, graph ->
|
||||
PseudoForestLayout(graph, 10.0f, 20.0f, 10.0f, { it, p -> it.isWrite && p == PseudoForestLayout.LayoutPhase.LAYERING }) { v ->
|
||||
(pane.getComponentFor(v) as DefaultVertexComponent).vertexSize()
|
||||
}
|
||||
}.also { pane ->
|
||||
val edge = QuadraticEdge<Edge>(
|
||||
delta = 0.0f,
|
||||
color = { if(it.isWrite) Color.ORANGE.darker() else Color.BLACK }
|
||||
)
|
||||
pane.setEdgeRenderer(edge)
|
||||
}
|
||||
|
||||
fun main() {
|
||||
val graph = getRegGraph()
|
||||
val pane = getRegPane(graph)
|
||||
|
||||
SwingUtilities.invokeLater {
|
||||
val frame = JFrame("Simple Register Dependency Graph")
|
||||
frame.defaultCloseOperation = JFrame.EXIT_ON_CLOSE
|
||||
frame.setSize(800, 600)
|
||||
frame.add(pane)
|
||||
frame.pack()
|
||||
frame.setLocationRelativeTo(null)
|
||||
frame.isVisible = true
|
||||
}
|
||||
}
|
@ -1,13 +1,29 @@
|
||||
package com.jaytux.altgraph.layout
|
||||
|
||||
interface ISize<S : ISize<S>> {
|
||||
fun width(): Float
|
||||
fun height(): Float
|
||||
fun copy(width: Float, height: Float): S
|
||||
}
|
||||
/**
|
||||
* A simple 2D size.
|
||||
*
|
||||
* This class is intentionally kept simple to easily allow wrapping library-specific size classes.
|
||||
*
|
||||
* @property width the width
|
||||
* @property height the height
|
||||
*/
|
||||
data class GSize(var width: Float, var height: Float)
|
||||
|
||||
interface IPoint<P : IPoint<P>> {
|
||||
fun x(): Float
|
||||
fun y(): Float
|
||||
fun copy(x: Float, y: Float): P
|
||||
/**
|
||||
* A simple 2D point.
|
||||
*
|
||||
* This class is intentionally kept simple to easily allow wrapping library-specific point classes.
|
||||
*
|
||||
* @property x the x coordinate
|
||||
* @property y the y coordinate
|
||||
*/
|
||||
data class GPoint(var x: Float, var y: Float) {
|
||||
/**
|
||||
* Adds two points component-wise.
|
||||
*
|
||||
* @param other the other point to add
|
||||
* @return a new point representing the sum of this point and [other]
|
||||
*/
|
||||
operator fun plus(other: GPoint): GPoint = GPoint(x + other.x, y + other.y)
|
||||
}
|
@ -2,12 +2,76 @@ package com.jaytux.altgraph.layout
|
||||
|
||||
import com.jaytux.altgraph.core.IGraph
|
||||
|
||||
interface ILayout<V, E, P : IPoint<P>, S : ISize<S>> {
|
||||
/**
|
||||
* A layout algorithm for graphs.
|
||||
*
|
||||
* The layout algorithm is responsible for positioning vertices in 2D space.
|
||||
*
|
||||
* The layout algorithm should cache the results of the (expensive) [compute] operation, such that the [location] and
|
||||
* [boundingBox] operation can be performed efficiently.
|
||||
* However, it is not required to invalidate the cache when [setGraph] or other mutating operations are called.
|
||||
*
|
||||
* @param V the vertex type
|
||||
* @param E the edge type
|
||||
*/
|
||||
interface ILayout<V, E> {
|
||||
/**
|
||||
* Gets the graph to layout.
|
||||
*
|
||||
* @return the graph
|
||||
*/
|
||||
fun graph(): IGraph<V, E>
|
||||
|
||||
/**
|
||||
* Sets the graph to layout.
|
||||
*
|
||||
* @param graph the graph
|
||||
*/
|
||||
fun setGraph(graph: IGraph<V, E>)
|
||||
|
||||
/**
|
||||
* Computes the layout.
|
||||
*
|
||||
* The computation results should be cached for future calls to [location] and [boundingBox].
|
||||
*/
|
||||
fun compute()
|
||||
fun location(vertex: V): P
|
||||
fun freezeAt(vertex: V, point: P)
|
||||
|
||||
/**
|
||||
* Gets the location of a vertex.
|
||||
*
|
||||
* If the layout has not been computed yet, this should invoke [compute].
|
||||
* However, if the layout has been computed, this should return the cached location.
|
||||
*/
|
||||
fun location(vertex: V): GPoint
|
||||
|
||||
/**
|
||||
* Freezes a vertex at a specific point.
|
||||
*
|
||||
* Not all layout algorithms support freezing vertices; check the documentation of the specific implementation.
|
||||
* Additionally, the algorithms that support freezing may restrict which vertices can be frozen, or put restrictions
|
||||
* on the position of frozen vertices.
|
||||
*
|
||||
* A frozen vertex will not be moved by subsequent calls to [compute] until it is unfrozen.
|
||||
*
|
||||
* @param vertex the vertex to freeze
|
||||
* @param point the point to freeze the vertex at
|
||||
*/
|
||||
fun freezeAt(vertex: V, point: GPoint)
|
||||
|
||||
/**
|
||||
* Unfreezes a vertex, allowing it to be moved by subsequent calls to [compute].
|
||||
*
|
||||
* If an implementation does not support freezing vertices, this method should do nothing.
|
||||
*
|
||||
* @param vertex the vertex to unfreeze
|
||||
*/
|
||||
fun unfreeze(vertex: V)
|
||||
fun boundingBox(): S
|
||||
|
||||
/**
|
||||
* Gets the bounding box of the layout.
|
||||
*
|
||||
* If the layout has not been computed yet, this should invoke [compute].
|
||||
* However, if the layout has been computed, this should return the cached bounding box.
|
||||
*/
|
||||
fun boundingBox(): GSize
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
package com.jaytux.altgraph.layout
|
||||
|
||||
data class MutablePair<T1, T2>(var x: T1, var y: T2)
|
@ -8,37 +8,107 @@ import kotlin.concurrent.atomics.AtomicBoolean
|
||||
import kotlin.concurrent.atomics.ExperimentalAtomicApi
|
||||
import kotlin.math.max
|
||||
|
||||
class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
|
||||
/**
|
||||
* A Sugiyama-style layout algorithm for "pseudo-forests" (such as control-flow graphs).
|
||||
*
|
||||
* This algorithm arranges the graph in layers, attempting to minimize edge crossings and distribute nodes evenly.
|
||||
* It treats the graph as a collection of disjoint acyclic graphs, disregarding back-edges during layout.
|
||||
*
|
||||
* You can improve the layout by increasing the iteration count via [setIterationCount], at the cost of longer
|
||||
* computation time (default: 3).
|
||||
*
|
||||
* Additionally, you can ignore certain edges during layout by providing a function to [ignoreInLayout]; during each
|
||||
* of the phases where edges can be ignored ([LayoutPhase.LAYERING] (deciding layers for each vertex),
|
||||
* [LayoutPhase.DISJOINTS] (deciding which sub-graphs to consider disjoint), and [LayoutPhase.SLOT_ASSIGNMENT] (deciding
|
||||
* the order of vertices in each layer)), the function will be called for each edge (once), and if it returns true,
|
||||
* the edge will be ignored for that phase. By default, no edges are ignored.
|
||||
*
|
||||
* This layout algorithm does not support freezing vertices; calling [freezeAt] will throw
|
||||
* [UnsupportedOperationException], and [unfreeze] is a no-op.
|
||||
* Additionally, [setGraph] and [setVertexSize] do not invalidate the cache.
|
||||
*
|
||||
* All mutation operations are synchronized, and thread-safe. However, the layout is not incremental, and doesn't track
|
||||
* graph changes. Any change to the graph or vertex sizes requires a full recomputation of the layout. The synchronized
|
||||
* operations are:
|
||||
* - Changing the graph ([setGraph]), measuring ([setVertexSize]),
|
||||
* - Querying layout-dependent values ([location], [boundingBox]),
|
||||
* - Computing the layout ([compute]).
|
||||
*
|
||||
* Locking is done via a simple spin-lock.
|
||||
*
|
||||
* @param V the vertex type
|
||||
* @param E the edge type
|
||||
* @param graph the graph to layout
|
||||
* @property horizontalMargin the horizontal margin between vertices in the same layer
|
||||
* @property disjoinXMargin the horizontal margin between disjoint subgraphs
|
||||
* @property interLayer the vertical margin between layers
|
||||
* @property ignoreInLayout a function that returns true if an edge should be ignored during layout (but are not inherently back-edges)
|
||||
* @property vertexSize a function that returns the size of a vertex, including its label offset
|
||||
*
|
||||
* @see ILayout
|
||||
*/
|
||||
class PseudoForestLayout<V, E>(
|
||||
graph: IGraph<V, E>,
|
||||
var horizontalMargin: Float,
|
||||
var disjoinXMargin: Float,
|
||||
var interLayer: Float,
|
||||
val pointZero: P, val sizeZero: S,
|
||||
vertexSize: (V) -> VertexSize<P, S>
|
||||
) : ILayout<V, E, P, S> {
|
||||
data class VertexSize<P : IPoint<P>, S : ISize<S>>(val vertex: S, val labelOffset: P, val labelSize: S) {
|
||||
fun fullSize(): S { // TODO: check the math here
|
||||
val minX = 0 + labelOffset.x()
|
||||
val minY = 0 + labelOffset.y()
|
||||
val maxX = vertex.width() + labelOffset.x() + labelSize.width()
|
||||
val maxY = vertex.height() + labelOffset.y() + labelSize.height()
|
||||
return vertex.copy(
|
||||
val ignoreInLayout: (E, LayoutPhase) -> Boolean = { _, _ -> false },
|
||||
vertexSize: (V) -> VertexSize
|
||||
) : ILayout<V, E>
|
||||
{
|
||||
/**
|
||||
* An enum representing the different phases of the layout process where edges can be ignored.
|
||||
* - [LAYERING]: during the layering phase, where back-edges are ignored to determine layers.
|
||||
* - [DISJOINTS]: during the disjoint graph computation phase, where edges connecting disjoint subgraphs can be ignored.
|
||||
* - [SLOT_ASSIGNMENT]: during the slot assignment phase, where certain edges may be ignored to optimize layout.
|
||||
*/
|
||||
enum class LayoutPhase {
|
||||
LAYERING,
|
||||
DISJOINTS,
|
||||
SLOT_ASSIGNMENT
|
||||
}
|
||||
|
||||
/**
|
||||
* A class representing data on the size of a vertex, including its label offset and size.
|
||||
*
|
||||
* @property vertex the size of the vertex itself
|
||||
* @property labelOffset the offset of the label relative to the vertex's center
|
||||
* @property labelSize the size of the label
|
||||
*/
|
||||
data class VertexSize(val vertex: GSize, val labelOffset: GPoint, val labelSize: GSize) {
|
||||
/**
|
||||
* Calculates the full size of the vertex including its label and offset.
|
||||
*
|
||||
* @return the full size of the vertex
|
||||
*/
|
||||
fun fullSize(): GSize { // TODO: check the math here
|
||||
val minX = 0 + labelOffset.x
|
||||
val minY = 0 + labelOffset.y
|
||||
val maxX = vertex.width + labelOffset.x + labelSize.width
|
||||
val maxY = vertex.height + labelOffset.y + labelSize.height
|
||||
return GSize(
|
||||
width = maxX - minX,
|
||||
height = maxY - minY
|
||||
)
|
||||
}
|
||||
|
||||
fun vCenterInBox(): P { // TODO: check the math here
|
||||
return labelOffset.copy(
|
||||
x = labelOffset.x() + vertex.width() / 2,
|
||||
y = labelOffset.y() + vertex.height() / 2
|
||||
/**
|
||||
* Calculates the center point of the vertex within its bounding box.
|
||||
*
|
||||
* @return the center point of the vertex
|
||||
*/
|
||||
fun vCenterInBox(): GPoint { // TODO: check the math here
|
||||
return GPoint(
|
||||
x = labelOffset.x + vertex.width / 2,
|
||||
y = labelOffset.y + vertex.height / 2
|
||||
)
|
||||
}
|
||||
}
|
||||
private var _graph: IGraph<V, E> = graph
|
||||
private val _positions = mutableMapOf<V, P>()
|
||||
private var _vertexSize: (V) -> VertexSize<P, S> = vertexSize
|
||||
private var _boundingBox: S? = null
|
||||
private val _positions = mutableMapOf<V, GPoint>()
|
||||
private var _vertexSize: (V) -> VertexSize = vertexSize
|
||||
private var _boundingBox: GSize? = null
|
||||
private var _repeat: Int = 3
|
||||
|
||||
@OptIn(ExperimentalAtomicApi::class)
|
||||
private var _lock: AtomicBoolean = AtomicBoolean(false)
|
||||
@ -50,77 +120,138 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
|
||||
}
|
||||
|
||||
try {
|
||||
println("Took lock in ${Exception().stackTrace.first()}")
|
||||
val x = block()
|
||||
_lock.store(false) // unlock after operation
|
||||
return x
|
||||
}
|
||||
finally {
|
||||
println("Releasing lock in ${Exception().stackTrace.first()}")
|
||||
_lock.store(false) // we can safely unlock
|
||||
}
|
||||
}
|
||||
|
||||
override fun graph(): IGraph<V, E> = _graph
|
||||
override fun setGraph(graph: IGraph<V, E>) { locked { _graph = graph } }
|
||||
fun setVertexSize(vertexSize: (V) -> VertexSize<P, S>) { locked { _vertexSize = vertexSize } }
|
||||
|
||||
// Either a vertex, or a dummy node to break up multi-layer-spanning edges
|
||||
private data class Connector<V>(
|
||||
var from: LayeredVertex<V>,
|
||||
var to: LayeredVertex<V>
|
||||
)
|
||||
private data class LayeredVertex<V>(
|
||||
val x: SumType<V, Connector<V>>
|
||||
) {
|
||||
/**
|
||||
* Gets the number of iterations the layout algorithm will perform to optimize the layout.
|
||||
*
|
||||
* @return the number of iterations
|
||||
*/
|
||||
fun getIterationCount(): Int = _repeat
|
||||
/**
|
||||
* Sets the number of iterations the layout algorithm will perform to optimize the layout.
|
||||
* More iterations may yield a better layout, but will take longer to compute.
|
||||
*
|
||||
* @param repeat the number of iterations
|
||||
*/
|
||||
fun setIterationCount(repeat: Int) { locked { _repeat = repeat } }
|
||||
|
||||
/**
|
||||
* Sets the vertex measuring function.
|
||||
*
|
||||
* @param vertexSize a function that returns the size of a vertex, including its label offset
|
||||
*/
|
||||
fun setVertexSize(vertexSize: (V) -> VertexSize) { locked { _vertexSize = vertexSize } }
|
||||
|
||||
private class Conn<V> private constructor(var from: Vert<V>?, var to: Vert<V>?, private val _id: Int) {
|
||||
constructor(from: Vert<V>?, to: Vert<V>?) : this(from, to, _nextId++) {}
|
||||
|
||||
override fun equals(other: Any?): Boolean = other is Conn<*> && other._id == _id
|
||||
override fun hashCode(): Int = _id.hashCode()
|
||||
override fun toString(): String = "C[${from?.x?.fold({ it.toString() }) { "C" } ?: "null"}->${to?.x?.fold({ it.toString() }) { "C" } ?: "null"}@$_id]"
|
||||
|
||||
companion object {
|
||||
private var _nextId = 0
|
||||
}
|
||||
}
|
||||
private class Vert<V>(val x: SumType<V, Conn<V>>) {
|
||||
constructor(x: V): this(x.sum1())
|
||||
constructor(x: Connector<V>): this(x.sum2())
|
||||
constructor(x: Conn<V>): this(x.sum2())
|
||||
|
||||
fun same(other: LayeredVertex<V>) = x.fold({ it1 ->
|
||||
override fun toString(): String = x.fold({ it.toString() }) { it.toString() }
|
||||
|
||||
fun same(other: Vert<V>) = x.fold({ it1 ->
|
||||
other.x.fold({ it2 -> it1 == it2 }) { false }
|
||||
}) { it1 ->
|
||||
other.x.fold({ false }) { it2 -> it1.from == it2.from && it1.to == it2.to }
|
||||
other.x.fold({ false }) { it2 -> it1 == it2 }
|
||||
}
|
||||
|
||||
// true is this is a direct parent of the other vertex/connector
|
||||
fun directParentOf(other: LayeredVertex<V>, graph: IGraph<V, *>): Boolean =
|
||||
x.fold({ xx ->
|
||||
// x is a vertex
|
||||
other.x.fold({
|
||||
// other is a vertex
|
||||
graph.xToY(xx, it) != null
|
||||
}) {
|
||||
// other is a connector
|
||||
it.from.same(this)
|
||||
}
|
||||
}) { xx ->
|
||||
// x is a connector -> we need to connect to other
|
||||
xx.to.same(other)
|
||||
fun directParentOf(other: Vert<V>, graph: IGraph<V, *>): Boolean = x.fold({ it1 ->
|
||||
other.x.fold({ it2 -> graph.xToY(it1, it2) != null }) { it2 ->
|
||||
it2.from?.x == it1
|
||||
}
|
||||
}) { it1 ->
|
||||
it1.to!!.same(other)
|
||||
}
|
||||
}
|
||||
private data class PreConnector<V>(
|
||||
var x: SumType<LayeredVertex<V>, PreConnector<V>>?,
|
||||
var y: SumType<LayeredVertex<V>, PreConnector<V>>?
|
||||
)
|
||||
private fun realVertex(v: V) = LayeredVertex(v.sum1())
|
||||
private fun buildChain(from: V, to: V, layerF: Int, layerT: Int): List<LayeredVertex<V>> {
|
||||
val chain = mutableListOf(LayeredVertex(Connector(LayeredVertex(from), LayeredVertex(to))))
|
||||
while(chain.size < layerT - layerF - 1) {
|
||||
val last = chain.last() // last is always Connector<V>, and last.to is always V (== to)
|
||||
val lastX = (last.x as SumType.SumT2<Connector<V>>).value
|
||||
val next = LayeredVertex(Connector(last, lastX.to))
|
||||
lastX.to = next // reconnect
|
||||
chain += next
|
||||
|
||||
private fun buildChain(from: V, to: V, layerF: Int, layerT: Int): List<Vert<V>> {
|
||||
val first = Vert(from)
|
||||
val last = Vert(to)
|
||||
val chain = List(layerT - layerF - 1) { Vert(Conn<V>(null, null)) }
|
||||
|
||||
chain.forEachIndexed { i, v ->
|
||||
if(i == 0) (v.x.asT2).from = first
|
||||
else (v.x.asT2).from = chain[i - 1]
|
||||
|
||||
if(i == chain.size - 1) (v.x.asT2).to = last
|
||||
else (v.x.asT2).to = chain[i + 1]
|
||||
}
|
||||
return chain
|
||||
}
|
||||
|
||||
private fun reachableFrom(start: V, phase: LayoutPhase): Set<V> {
|
||||
val seen = mutableSetOf<V>()
|
||||
val queue = ArrayDeque<V>()
|
||||
queue.add(start)
|
||||
|
||||
while(!queue.isEmpty()) {
|
||||
val v = queue.removeFirst()
|
||||
if(seen.add(v)) {
|
||||
_graph.successors(v).forEach { (succ, edge) ->
|
||||
if(ignoreInLayout(edge, phase)) return@forEach
|
||||
if(succ !in seen) queue.addLast(succ)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return seen
|
||||
}
|
||||
|
||||
private fun computeCrossings(lTop: List<Vert<V>>, lBot: List<Vert<V>>, edges: List<Pair<Vert<V>, Vert<V>>>): Int {
|
||||
var count = 0
|
||||
val conns = edges.map { (top, bot) -> lTop.indexOf(top) to lBot.indexOf(bot) }.sortedWith { (t1, b1), (t2, b2) ->
|
||||
if (t1 != t2) t1 - t2
|
||||
else b1 - b2
|
||||
}
|
||||
|
||||
conns.forEachIndexed { i, conn ->
|
||||
for(j in i + 1 until conns.size) {
|
||||
val other = conns[j]
|
||||
|
||||
if(conn.first == other.first || conn.second == other.second) continue // shared vertex -> cannot cross
|
||||
|
||||
val topFirst = conn.first < other.first
|
||||
val botFirst = conn.second < other.second
|
||||
|
||||
if(topFirst != botFirst) count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
override fun compute() {
|
||||
println("Acquiring lock")
|
||||
locked {
|
||||
print("Starting layouting")
|
||||
_positions.clear()
|
||||
|
||||
// Assign a layer to each vertex by traversing depth-first and ignoring back-edges.
|
||||
val roots = _graph.roots()
|
||||
if (roots.isEmpty()) { // Only reachable nodes matter.
|
||||
_boundingBox = sizeZero
|
||||
_boundingBox = GSize(0.0f, 0.0f)
|
||||
return
|
||||
}
|
||||
val layers = mutableMapOf<V, Pair<Int, MutableSet<V>>>()
|
||||
@ -132,11 +263,16 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
|
||||
val (layer, dep) = layers.getOrPut(vertex) { 0 to mutableSetOf() }
|
||||
|
||||
val succLayer = layer + 1
|
||||
_graph.successors(vertex).forEach { (succ, _) ->
|
||||
_graph.successors(vertex).forEach { (succ, edge) ->
|
||||
if(ignoreInLayout(edge, LayoutPhase.LAYERING)) {
|
||||
return@forEach
|
||||
}
|
||||
if (succ in onPath) return@forEach
|
||||
dep += succ
|
||||
|
||||
if (succ in onPath) return@forEach
|
||||
layers[succ]?.let { (l, sDep) ->
|
||||
dep += sDep
|
||||
|
||||
val delta = succLayer - l
|
||||
if (delta > 0) {
|
||||
layers[succ] = succLayer to (sDep)
|
||||
@ -145,16 +281,21 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
|
||||
?: (succLayer to mutableSetOf())
|
||||
}
|
||||
}
|
||||
} ?: run {
|
||||
layers[succ] = succLayer to mutableSetOf()
|
||||
}
|
||||
queue.addLast(succ to onPath + vertex)
|
||||
}
|
||||
|
||||
// ensure dependents are always up to date
|
||||
layers.values.filter { it.second.contains(vertex) }.forEach { (_, d) -> d.addAll(dep) }
|
||||
}
|
||||
|
||||
// Cache node sizes
|
||||
val vertexSizes = layers.mapValues { (v, _) -> _vertexSize(v).let { it.fullSize() to it.vCenterInBox() } }
|
||||
|
||||
// Compute layer y positions (and thus the bounding box height).
|
||||
val layerCount = layers.maxOf { it.value.first }
|
||||
val layerCount = layers.maxOf { it.value.first } + 1
|
||||
val layerHeights = MutableList(layerCount) { 0.0f }
|
||||
|
||||
var minOffset = Float.POSITIVE_INFINITY
|
||||
@ -162,10 +303,10 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
|
||||
layers.forEach { (vertex, pair) ->
|
||||
val (layer, _) = pair
|
||||
val size = vertexSizes[vertex]!!
|
||||
layerHeights[layer] = max(layerHeights[layer], size.first.height())
|
||||
layerHeights[layer] = max(layerHeights[layer], size.first.height)
|
||||
if(layer == 0) {
|
||||
// Take into account vertex bounding box offset
|
||||
val delta = size.second.y()
|
||||
val delta = size.second.y
|
||||
if(delta < minOffset) minOffset = delta
|
||||
if(delta > maxOffset) maxOffset = delta
|
||||
}
|
||||
@ -180,8 +321,7 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
|
||||
|
||||
// Compute disjoint graphs
|
||||
val disjoint = roots.fold(listOf<Set<V>>()) { acc, root ->
|
||||
val reachable = layers[root]?.second?.toMutableSet() ?: return@fold acc
|
||||
|
||||
val reachable = reachableFrom(root, LayoutPhase.DISJOINTS).toMutableSet()
|
||||
val dedup = acc.mapNotNull { other ->
|
||||
val inter = reachable intersect other
|
||||
if(inter.isEmpty()) other // fully disjoint -> keep
|
||||
@ -199,7 +339,7 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
|
||||
// Put each vertex in a list by layer
|
||||
val layered = List(layerCount) { layer ->
|
||||
sub.mapNotNull {
|
||||
if(layers[it]?.first == layer) realVertex(it)
|
||||
if(layers[it]?.first == layer) Vert(it)
|
||||
else null
|
||||
}.toMutableList()
|
||||
}
|
||||
@ -217,16 +357,31 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
|
||||
layered[idx + offset + 1] += dummy
|
||||
}
|
||||
}
|
||||
else if(otherLayer < idx - 1) {
|
||||
val chain = buildChain(other, node, otherLayer, idx)
|
||||
chain.forEachIndexed { offset, dummy ->
|
||||
layered[otherLayer + offset + 1] += dummy
|
||||
}
|
||||
}
|
||||
}
|
||||
}) {} // do nothing on dummy nodes
|
||||
}
|
||||
}
|
||||
|
||||
val layerWidths = MutableList(layerCount) { -horizontalMargin } // avoid double adding margin on 1st node
|
||||
|
||||
// Layout roots
|
||||
layered[0].forEach { root ->
|
||||
root.x.fold({ node ->
|
||||
val w = vertexSizes[node]!!.first.width
|
||||
layerWidths[0] += w + horizontalMargin
|
||||
}) {}
|
||||
}
|
||||
|
||||
// Layer-by-layer, assign x slots (not yet positions)
|
||||
for(i in 1 until layered.size) {
|
||||
// Barycenter heuristic: average of parents' slots
|
||||
val heuristic = { v: LayeredVertex<V> ->
|
||||
val heuristic = { v: Vert<V> ->
|
||||
val parents = layered[i - 1].mapIndexedNotNull { idx, p -> if(p.directParentOf(v, _graph)) idx.toFloat() else null }
|
||||
parents.sum() / parents.size
|
||||
}
|
||||
@ -234,7 +389,7 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
|
||||
|
||||
layered[i].forEach { v ->
|
||||
v.x.fold({ node ->
|
||||
val w = vertexSizes[node]!!.first.width()
|
||||
val w = vertexSizes[node]!!.first.width
|
||||
layerWidths[i] += w + horizontalMargin
|
||||
}) { /* do nothing */ }
|
||||
}
|
||||
@ -244,15 +399,59 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
|
||||
val maxWidth = layerWidths.max()
|
||||
|
||||
// TODO: do some reorderings to minimize #crossings?
|
||||
println(" - Optimizing slot assignments")
|
||||
layered.forEachIndexed { idx, list ->
|
||||
println(" - Layer $idx: $list")
|
||||
}
|
||||
|
||||
val edges = mutableListOf<MutableList<Pair<Vert<V>, Vert<V>>>>()
|
||||
for(i in 1 until layered.size) {
|
||||
edges.add(mutableListOf())
|
||||
val current = edges[i - 1]
|
||||
|
||||
layered[i].forEach { vBot ->
|
||||
val forVBot = layered[i - 1].filter {
|
||||
it.directParentOf(vBot, _graph) || vBot.directParentOf(it, _graph)
|
||||
}.map { it to vBot }
|
||||
current.addAll(forVBot)
|
||||
}
|
||||
}
|
||||
|
||||
repeat(_repeat) {
|
||||
val optP0 = layered[0].permutations().map { perm ->
|
||||
val crossings = computeCrossings(perm, layered[1], edges[0])
|
||||
println(" - Layer 0 permutation $perm (to ${layered[1]} has $crossings crossings")
|
||||
perm to crossings
|
||||
}.fold(Float.POSITIVE_INFINITY to listOf<Vert<V>>()) { (accCost, accSeq), (perm, cost) ->
|
||||
if (accCost > cost) cost.toFloat() to perm
|
||||
else accCost to accSeq
|
||||
}
|
||||
layered[0].clear()
|
||||
layered[0].addAll(optP0.second.toMutableList())
|
||||
for (i in 1 until layered.size) {
|
||||
val optPi = layered[i].permutations().map { perm ->
|
||||
val crossings = computeCrossings(layered[i - 1], perm, edges[i - 1])
|
||||
println(" - Layer $i permutation $perm (from ${layered[i - 1]} has $crossings crossings")
|
||||
perm to crossings
|
||||
}.fold(Float.POSITIVE_INFINITY to listOf<Vert<V>>()) { (accCost, accSeq), (perm, cost) ->
|
||||
if (accCost > cost) cost.toFloat() to perm
|
||||
else accCost to accSeq
|
||||
}
|
||||
layered[i].clear()
|
||||
layered[i].addAll(optPi.second.toMutableList())
|
||||
}
|
||||
}
|
||||
|
||||
// Assign x positions
|
||||
layered.forEachIndexed { idx, layer ->
|
||||
println(" - Positioning layer $idx")
|
||||
var currentX = currentXZero + (maxWidth - layerWidths[idx]) / 2
|
||||
layer.forEach { v ->
|
||||
v.x.fold({ node ->
|
||||
val offset = vertexSizes[node]!!.second
|
||||
_positions[node] = pointZero.copy(x = currentX + offset.x(), y = layerY[idx] + offset.y())
|
||||
currentX += vertexSizes[node]!!.first.width() + horizontalMargin
|
||||
_positions[node] = GPoint(x = currentX + offset.x, y = layerY[idx] + offset.y)
|
||||
println(" - Put vertex $node at ${_positions[node]}")
|
||||
currentX += vertexSizes[node]!!.first.width + horizontalMargin
|
||||
}) { /* do nothing */ }
|
||||
}
|
||||
}
|
||||
@ -263,20 +462,22 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
|
||||
|
||||
// Compute the bounding box
|
||||
// min x and y are 0
|
||||
_boundingBox = sizeZero.copy(currentXZero - disjoinXMargin, layerY.last() + layerHeights.last() / 2 + offset)
|
||||
_boundingBox = GSize(currentXZero - disjoinXMargin, layerY.last() + layerHeights.last() / 2 + offset)
|
||||
println("Done layouting")
|
||||
}
|
||||
println("Released lock")
|
||||
}
|
||||
|
||||
override fun location(vertex: V): P {
|
||||
if(vertex !in _graph.vertices()) throw GraphException.vertexNotFound(vertex)
|
||||
override fun location(vertex: V): GPoint {
|
||||
if (vertex !in _graph.vertices()) throw GraphException.vertexNotFound(vertex)
|
||||
return _positions[vertex] ?: run {
|
||||
compute()
|
||||
_positions[vertex]!!
|
||||
_positions[vertex] ?: throw IllegalArgumentException("Vertex $vertex was not layout-ed: $_positions")
|
||||
}
|
||||
}
|
||||
|
||||
override fun freezeAt(vertex: V, point: P) = throw UnsupportedOperationException("PseudoForestLayout does not allow freezing vertices.")
|
||||
override fun freezeAt(vertex: V, point: GPoint) = throw UnsupportedOperationException("PseudoForestLayout does not allow freezing vertices.")
|
||||
override fun unfreeze(vertex: V) { /* no-op: cannot freeze vertices */ }
|
||||
|
||||
override fun boundingBox(): S = _boundingBox ?: run { compute(); _boundingBox!! }
|
||||
override fun boundingBox(): GSize = locked { _boundingBox ?: run { compute(); _boundingBox!! } }
|
||||
}
|
@ -1,25 +1,92 @@
|
||||
package com.jaytux.altgraph.layout
|
||||
|
||||
/**
|
||||
* A sum type (aka tagged union, variant, discriminated union) of two types.
|
||||
*
|
||||
* At any time, a SumType holds either a `T1` or a `T2` value.
|
||||
*
|
||||
* @param T1 the first type
|
||||
* @param T2 the second type
|
||||
*/
|
||||
sealed class SumType<out T1, out T2> {
|
||||
/**
|
||||
* The first alternative of the sum type.
|
||||
*
|
||||
* This class overrides both [equals] and [hashCode] to refer through to the contained value.
|
||||
* Specifically, equality is defined such that only the following hold:
|
||||
* - `SumT1(x) == SumT1(y)` if and only if `x == y`; or
|
||||
* - `SumT1(x) == y` if and only if `x == y`.
|
||||
*
|
||||
* @param T1 the type of the value
|
||||
* @property value the value
|
||||
*/
|
||||
class SumT1<out T1>(val value: T1) : SumType<T1, Nothing>() {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is SumT1<*> && value == other.value
|
||||
(other is SumT1<*> && value == other.value) || value == other
|
||||
override fun hashCode(): Int = value.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* The second alternative of the sum type.
|
||||
*
|
||||
* This class overrides both [equals] and [hashCode] to refer through to the contained value.
|
||||
* Specifically, equality is defined such that only the following hold:
|
||||
* - `SumT2(x) == SumT2(y)` if and only if `x == y`; or
|
||||
* - `SumT2(x) == y` if and only if `x == y`.
|
||||
*
|
||||
* @param T2 the type of the value
|
||||
* @property value the value
|
||||
*/
|
||||
class SumT2<out T2>(val value: T2) : SumType<Nothing, T2>() {
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is SumT2<*> && value == other.value
|
||||
(other is SumT2<*> && value == other.value) || value == other
|
||||
override fun hashCode(): Int = value.hashCode()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern matches on the sum type.
|
||||
*
|
||||
* If the sum type holds a `T1`, invokes [onT1] with the contained value and returns its result.
|
||||
* Otherwise, invokes [onT2] with the contained value and returns its result.
|
||||
*
|
||||
* @param R the return type of the pattern match
|
||||
* @param onT1 the function to invoke if the sum type holds a `T1`
|
||||
* @param onT2 the function to invoke if the sum type holds a `T2`
|
||||
* @return the result of invoking either [onT1] or [onT2]
|
||||
*/
|
||||
fun <R> fold(onT1: (T1) -> R, onT2: (T2) -> R): R = when(this) {
|
||||
is SumT1 -> onT1(value)
|
||||
is SumT2 -> onT2(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the contained value as a `T1`, or throws if the sum type holds a `T2`.
|
||||
*/
|
||||
val asT1: T1
|
||||
get() = fold({ it }) { throw IllegalStateException("Not a T1: $this") }
|
||||
|
||||
/**
|
||||
* Gets the contained value as a `T2`, or throws if the sum type holds a `T1`.
|
||||
*/
|
||||
val asT2: T2
|
||||
get() = fold({ throw IllegalStateException("Not a T2: $this") }) { it }
|
||||
|
||||
override fun toString(): String = fold({ it.toString() }) { it.toString() }
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Constructs a [SumType] holding a `T1`.
|
||||
*
|
||||
* @receiver the value to hold
|
||||
* @return a [SumType] holding the receiver as a `T1`
|
||||
*/
|
||||
fun <T> T.sum1(): SumType<T, Nothing> = SumT1(this)
|
||||
/**
|
||||
* Constructs a [SumType] holding a `T2`.
|
||||
*
|
||||
* @receiver the value to hold
|
||||
* @return a [SumType] holding the receiver as a `T2`
|
||||
*/
|
||||
fun <T> T.sum2(): SumType<Nothing, T> = SumT2(this)
|
||||
}
|
||||
}
|
13
src/main/kotlin/com/jaytux/altgraph/layout/Util.kt
Normal file
13
src/main/kotlin/com/jaytux/altgraph/layout/Util.kt
Normal file
@ -0,0 +1,13 @@
|
||||
package com.jaytux.altgraph.layout
|
||||
|
||||
fun <T> List<T>.permutations(): Sequence<List<T>> = sequence {
|
||||
if(isEmpty()) yield(emptyList())
|
||||
else {
|
||||
for(i in indices) {
|
||||
val elem = this@permutations[i]
|
||||
for(perm in (this@permutations - elem).permutations()) {
|
||||
yield(listOf(elem) + perm)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
66
src/main/kotlin/com/jaytux/altgraph/swing/DefaultEdge.kt
Normal file
66
src/main/kotlin/com/jaytux/altgraph/swing/DefaultEdge.kt
Normal file
@ -0,0 +1,66 @@
|
||||
package com.jaytux.altgraph.swing
|
||||
|
||||
import java.awt.Color
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.Point
|
||||
import java.awt.Polygon
|
||||
import java.awt.geom.QuadCurve2D
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class QuadraticEdge<E>(
|
||||
var delta: Float = 0.2f,
|
||||
var arrowLen: Int = 10,
|
||||
var color: (E) -> Color = { Color.BLACK },
|
||||
var arrowAngle: Double = Math.PI / 6.0
|
||||
) : IEdgeRenderer<E> {
|
||||
override fun drawEdge(g: Graphics2D, from: Point, to: Point, meta: E, offsetFrom: Float, offsetTo: Float) {
|
||||
val g2 = g.create() as Graphics2D
|
||||
g2.color = color(meta)
|
||||
|
||||
val len = from.distance(to).toFloat()
|
||||
val xLen = delta * len
|
||||
|
||||
val fx = from.x.toFloat(); val fy = from.y.toFloat()
|
||||
val tx = to.x.toFloat(); val ty = to.y.toFloat()
|
||||
|
||||
val midX = (fx + tx) / 2
|
||||
val midY = (fy + ty) / 2
|
||||
|
||||
|
||||
val x1 = tx - midX; val y1 = ty - midY
|
||||
val fac = xLen / sqrt(x1 * x1 + y1 * y1)
|
||||
|
||||
val cx = (midX + y1 * fac)
|
||||
val cy = (midY - x1 * fac)
|
||||
|
||||
val dx1 = cx - fx; val dy1 = cy - fy; val d1fac = offsetFrom / sqrt(dx1 * dx1 + dy1 * dy1)
|
||||
val dx2 = tx - cx; val dy2 = ty - cy; val d2fac = offsetTo / sqrt(dx2 * dx2 + dy2 * dy2)
|
||||
|
||||
val x1_ = fx + dx1 * d1fac; val y1_ = fy + dy1 * d1fac
|
||||
val x2_ = tx - dx2 * d2fac; val y2_ = ty - dy2 * d2fac
|
||||
val curve = QuadCurve2D.Float(
|
||||
x1_, y1_,
|
||||
cx, cy,
|
||||
x2_, y2_
|
||||
)
|
||||
g2.draw(curve)
|
||||
|
||||
// draw arrowhead at the end of the curve
|
||||
val angle = atan2(y2_ - cy, x2_ - cx)
|
||||
val arrowX1 = x2_ - arrowLen * cos(angle - arrowAngle).toFloat()
|
||||
val arrowY1 = y2_ - arrowLen * sin(angle - arrowAngle).toFloat()
|
||||
val arrowX2 = x2_ - arrowLen * cos(angle + arrowAngle).toFloat()
|
||||
val arrowY2 = y2_ - arrowLen * sin(angle + arrowAngle).toFloat()
|
||||
val arrowHead = Polygon(
|
||||
intArrayOf(x2_.toInt(), arrowX1.toInt(), arrowX2.toInt()),
|
||||
intArrayOf(y2_.toInt(), arrowY1.toInt(), arrowY2.toInt()),
|
||||
3
|
||||
)
|
||||
g2.fill(arrowHead)
|
||||
}
|
||||
}
|
||||
|
||||
fun <E> defaultEdgeRenderer(): IEdgeRenderer<E> = QuadraticEdge()
|
@ -0,0 +1,87 @@
|
||||
package com.jaytux.altgraph.swing
|
||||
|
||||
import com.jaytux.altgraph.layout.PseudoForestLayout
|
||||
import java.awt.*
|
||||
import java.awt.RenderingHints.KEY_ANTIALIASING
|
||||
import java.awt.RenderingHints.VALUE_ANTIALIAS_ON
|
||||
import java.awt.geom.AffineTransform
|
||||
import java.awt.geom.Ellipse2D
|
||||
import javax.swing.JLabel
|
||||
|
||||
class DefaultVertexComponent(
|
||||
label: String,
|
||||
private var _shape: Shape = Ellipse2D.Double(0.0, 0.0, 30.0, 30.0),
|
||||
var labelOffset: Point = Point(20, 7),
|
||||
) : IDrawable {
|
||||
val label = JLabel(label)
|
||||
private lateinit var _preferredSize: Dimension
|
||||
private var _arrowTarget: Point = Point(0, 0)
|
||||
private var _arrowTargetOffset: Float = 0.0f
|
||||
var fillColor: Color = Color.LIGHT_GRAY
|
||||
var borderStroke: Stroke = BasicStroke(1.0f)
|
||||
var borderColor: Color = Color.BLACK
|
||||
|
||||
var shape: Shape
|
||||
get() = _shape
|
||||
set(value) {
|
||||
_shape = value
|
||||
_arrowTarget = Point(value.bounds.width / 2, value.bounds.height / 2)
|
||||
_arrowTargetOffset = (value.bounds.width + value.bounds.height).toFloat() / 4.0f
|
||||
updatePreferredSize()
|
||||
}
|
||||
|
||||
init {
|
||||
updatePreferredSize()
|
||||
shape = _shape // to initialize arrow target and offset
|
||||
}
|
||||
|
||||
fun updatePreferredSize() {
|
||||
val bounds = shape.bounds
|
||||
val textSize = label.preferredSize
|
||||
|
||||
val cx = bounds.width / 2; val cy = bounds.height / 2
|
||||
val tx = cx + labelOffset.x; val ty = cy + labelOffset.y - textSize.height / 2
|
||||
|
||||
val textRect = Rectangle(tx, ty, textSize.width, textSize.height)
|
||||
val total = bounds.union(textRect)
|
||||
_preferredSize = Dimension(total.width, total.height)
|
||||
}
|
||||
|
||||
override fun draw(graphics: Graphics2D) {
|
||||
val g = graphics.create() as Graphics2D
|
||||
g.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON)
|
||||
val shapeBounds = shape.bounds
|
||||
val cx = shapeBounds.width / 2; val cy = shapeBounds.height / 2
|
||||
val tx = AffineTransform.getTranslateInstance((cx - shapeBounds.centerX), (cy - shapeBounds.centerY))
|
||||
val transShape = tx.createTransformedShape(shape)
|
||||
|
||||
g.color = fillColor
|
||||
g.fill(transShape)
|
||||
g.color = borderColor
|
||||
g.stroke = borderStroke
|
||||
g.draw(transShape)
|
||||
|
||||
val textSize = label.preferredSize
|
||||
val lx = cx + labelOffset.x; val ly = cy + labelOffset.y - textSize.height / 2
|
||||
label.size = textSize
|
||||
g.translate(lx, ly)
|
||||
label.paint(g)
|
||||
|
||||
g.dispose()
|
||||
}
|
||||
|
||||
fun vertexSize(): PseudoForestLayout.VertexSize {
|
||||
val size = _preferredSize
|
||||
val lSize = label.preferredSize
|
||||
return PseudoForestLayout.VertexSize(size.graph(), labelOffset.graph(), lSize.graph())
|
||||
}
|
||||
|
||||
fun asRenderData(): IVertexRenderer.RenderData =
|
||||
IVertexRenderer.RenderData(this, _arrowTarget, _arrowTargetOffset)
|
||||
}
|
||||
|
||||
fun <V> defaultRenderer(
|
||||
toString: (V) -> String = { it.toString() }
|
||||
) : IVertexRenderer<V> = IVertexRenderer { v: V ->
|
||||
DefaultVertexComponent(toString(v)).asRenderData()
|
||||
}
|
21
src/main/kotlin/com/jaytux/altgraph/swing/Functional.kt
Normal file
21
src/main/kotlin/com/jaytux/altgraph/swing/Functional.kt
Normal file
@ -0,0 +1,21 @@
|
||||
package com.jaytux.altgraph.swing
|
||||
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.Point
|
||||
|
||||
interface IDrawable {
|
||||
fun draw(graphics: Graphics2D)
|
||||
}
|
||||
|
||||
fun interface IVertexRenderer<V> {
|
||||
data class RenderData(
|
||||
val drawer: IDrawable,
|
||||
val arrowTarget: Point,
|
||||
val arrowTargetOffset: Float
|
||||
)
|
||||
fun getDrawable(v: V): RenderData
|
||||
}
|
||||
|
||||
fun interface IEdgeRenderer<E> {
|
||||
fun drawEdge(g: Graphics2D, from: Point, to: Point, meta: E, offsetFrom: Float, offsetTo: Float)
|
||||
}
|
@ -1,29 +1,12 @@
|
||||
package com.jaytux.altgraph.swing
|
||||
|
||||
import com.jaytux.altgraph.layout.IPoint
|
||||
import com.jaytux.altgraph.layout.ISize
|
||||
import com.jaytux.altgraph.layout.GPoint
|
||||
import com.jaytux.altgraph.layout.GSize
|
||||
import java.awt.Dimension
|
||||
import java.awt.Point
|
||||
|
||||
class GSize(val size: Dimension) : ISize<GSize> {
|
||||
override fun width(): Float = size.width.toFloat()
|
||||
override fun height(): Float = size.height.toFloat()
|
||||
override fun copy(width: Float, height: Float): GSize =
|
||||
GSize(Dimension(width.toInt(), height.toInt()))
|
||||
fun GSize.swing() = Dimension(width.toInt(), height.toInt())
|
||||
fun GPoint.swing() = Point(x.toInt(), y.toInt())
|
||||
|
||||
override fun hashCode(): Int = size.hashCode()
|
||||
override fun equals(other: Any?): Boolean = other is GSize && size == other.size
|
||||
override fun toString(): String = "$size"
|
||||
}
|
||||
|
||||
class GPoint(val point: Point) : IPoint<GPoint> {
|
||||
override fun x(): Float = point.x.toFloat()
|
||||
override fun y(): Float = point.y.toFloat()
|
||||
override fun copy(x: Float, y: Float): GPoint =
|
||||
GPoint(Point(x.toInt(), y.toInt()))
|
||||
|
||||
override fun hashCode(): Int = point.hashCode()
|
||||
override fun equals(other: Any?): Boolean =
|
||||
other is GPoint && point == other.point
|
||||
override fun toString(): String = "$point"
|
||||
}
|
||||
fun Dimension.graph() = GSize(width.toFloat(), height.toFloat())
|
||||
fun Point.graph() = GPoint(x.toFloat(), y.toFloat())
|
131
src/main/kotlin/com/jaytux/altgraph/swing/GraphPane.kt
Normal file
131
src/main/kotlin/com/jaytux/altgraph/swing/GraphPane.kt
Normal file
@ -0,0 +1,131 @@
|
||||
package com.jaytux.altgraph.swing
|
||||
|
||||
import com.jaytux.altgraph.core.IGraph
|
||||
import com.jaytux.altgraph.layout.ILayout
|
||||
import java.awt.Graphics
|
||||
import java.awt.Graphics2D
|
||||
import java.awt.Point
|
||||
import java.awt.event.MouseEvent
|
||||
import java.awt.event.MouseMotionListener
|
||||
import java.awt.geom.AffineTransform
|
||||
import javax.swing.JPanel
|
||||
|
||||
class GraphPane<V, E>(
|
||||
val graph: IGraph<V, E>,
|
||||
layout: (GraphPane<V, E>, IGraph<V, E>) -> ILayout<V, E>
|
||||
) : JPanel()
|
||||
{
|
||||
private val _delta = AffineTransform()
|
||||
private var _zoom = 1.0
|
||||
private var _panX = 0.0
|
||||
private var _panY = 0.0
|
||||
|
||||
private val _layout: ILayout<V, E>
|
||||
private var _renderer: IVertexRenderer<V> = defaultRenderer()
|
||||
private var _edgeRenderer: IEdgeRenderer<E> = defaultEdgeRenderer()
|
||||
private val _vertices = mutableMapOf<V, IVertexRenderer.RenderData>()
|
||||
|
||||
init {
|
||||
this.layout = null
|
||||
_delta.setToIdentity()
|
||||
_layout = layout(this, graph)
|
||||
|
||||
addMouseWheelListener { event ->
|
||||
val delta = event.preciseWheelRotation
|
||||
if(delta < 0) {
|
||||
_zoom *= 1.1
|
||||
} else {
|
||||
_zoom /= 1.1
|
||||
}
|
||||
transform()
|
||||
}
|
||||
|
||||
addMouseMotionListener(object : MouseMotionListener {
|
||||
var last: Point? = null
|
||||
|
||||
override fun mouseDragged(e: MouseEvent?) {
|
||||
e?.let {
|
||||
last?.let {
|
||||
val dx = e.x - it.x
|
||||
val dy = e.y - it.y
|
||||
_panX += dx
|
||||
_panY += dy
|
||||
transform()
|
||||
}
|
||||
|
||||
last = Point(e.x, e.y)
|
||||
}
|
||||
}
|
||||
|
||||
override fun mouseMoved(e: MouseEvent?) {
|
||||
e?.let { last = it.point }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun transform() {
|
||||
_delta.setToIdentity()
|
||||
_delta.translate(_panX, _panY)
|
||||
_delta.scale(_zoom, _zoom)
|
||||
repaint()
|
||||
}
|
||||
|
||||
fun relayout() {
|
||||
_layout.compute()
|
||||
repaint()
|
||||
}
|
||||
|
||||
fun getComponentFor(v: V): IDrawable =
|
||||
getRenderDataFor(v).drawer
|
||||
|
||||
fun getRenderDataFor(v: V): IVertexRenderer.RenderData =
|
||||
_vertices.getOrPut(v) { _renderer.getDrawable(v) }
|
||||
|
||||
fun setRenderer(renderer: IVertexRenderer<V>) {
|
||||
_renderer = renderer
|
||||
_vertices.clear()
|
||||
repaint()
|
||||
}
|
||||
|
||||
fun setEdgeRenderer(renderer: IEdgeRenderer<E>) {
|
||||
_edgeRenderer = renderer
|
||||
repaint()
|
||||
}
|
||||
|
||||
fun resetComponents() {
|
||||
_vertices.clear()
|
||||
repaint()
|
||||
}
|
||||
|
||||
override fun paintComponent(g: Graphics?) {
|
||||
super.paintComponent(g)
|
||||
if(g == null) return
|
||||
|
||||
val g2 = g.create() as Graphics2D
|
||||
g2.transform = _delta
|
||||
graph.vertices().forEach { v -> getRenderDataFor(v) } // ensure all vertices are rendered
|
||||
_vertices.forEach { (v, c) ->
|
||||
if(v !in graph.vertices()) return@forEach
|
||||
val pos = _layout.location(v).swing()
|
||||
|
||||
val g3 = g2.create() as Graphics2D
|
||||
g3.translate(pos.x, pos.y)
|
||||
c.drawer.draw(g3)
|
||||
g3.dispose()
|
||||
}
|
||||
|
||||
graph.edges().forEach { (e, vv) ->
|
||||
val first = getRenderDataFor(vv.first)
|
||||
val second = getRenderDataFor(vv.second)
|
||||
val from = _layout.location(vv.first) + first.arrowTarget.graph()
|
||||
val to = _layout.location(vv.second) + second.arrowTarget.graph()
|
||||
|
||||
_edgeRenderer.drawEdge(g2,
|
||||
from.swing(),
|
||||
to.swing(),
|
||||
e, first.arrowTargetOffset, second.arrowTargetOffset)
|
||||
}
|
||||
|
||||
g2.dispose()
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user