Compare commits

...

6 Commits

Author SHA1 Message Date
6f0f5d05b6 Prepare alpha-release 2025-09-05 11:21:41 +02:00
9f78c3e44a Working on minimizing crossings 2025-09-05 10:32:23 +02:00
2d36d60020 Fixed layouting, added another example 2025-09-02 18:08:46 +02:00
d460c71a33 Rendering 2025-09-02 14:37:12 +02:00
4a5db84148 Documentation 2025-08-22 21:21:18 +02:00
64560b172b Small geometry overhaul 2025-08-22 20:15:47 +02:00
15 changed files with 1083 additions and 120 deletions

View File

@ -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"))
}
}
}

View File

@ -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> =

View File

@ -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.")
}

View 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
}
}

View 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
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -1,3 +0,0 @@
package com.jaytux.altgraph.layout
data class MutablePair<T1, T2>(var x: T1, var y: T2)

View File

@ -8,37 +8,97 @@ 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.
*
* 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
@OptIn(ExperimentalAtomicApi::class)
private var _lock: AtomicBoolean = AtomicBoolean(false)
@ -50,93 +110,162 @@ 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>>
) {
/**
* 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)
fun directParentOf(other: Vert<V>, graph: IGraph<V, *>): Boolean = x.fold({ it1 ->
other.x.fold({ it2 -> graph.xToY(it1, it2) != null }) { it2 ->
println(" - Checking direct parenthood between $it1 and dummy $it2 (${it2.from}): ${it2.from?.x == it1}")
it2.from?.x == it1
}
}) { xx ->
// x is a connector -> we need to connect to other
xx.to.same(other)
}) { 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]
}
println(" - Breaking edge $from -> $to with chain: $chain")
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
println(" - Computing crossings between layers:")
println(" - Top: $lTop")
println(" - Bot: $lBot")
println(" - Edges: $edges")
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
}
println(" - Connections (by index): $conns")
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) {
println(" - Crossing between ${lTop[conn.first]}->${lBot[conn.second]} and ${lTop[other.first]}->${lBot[other.second]}")
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>>>()
val queue = ArrayDeque<Pair<V, Set<V>>>(roots.size * 2)
queue.addAll(roots.map { it to emptySet() })
println(" - Computing layers from roots: $roots")
while (!queue.isEmpty()) {
val (vertex, onPath) = queue.removeFirst()
val (layer, dep) = layers.getOrPut(vertex) { 0 to mutableSetOf() }
println(" - Visiting $vertex (layer $layer), path=$onPath, deps=$dep")
val succLayer = layer + 1
_graph.successors(vertex).forEach { (succ, _) ->
_graph.successors(vertex).forEach { (succ, edge) ->
if(ignoreInLayout(edge, LayoutPhase.LAYERING)) {
println(" - Ignoring edge $edge for layout")
return@forEach
}
if (succ in onPath) return@forEach
dep += succ
if (succ in onPath) return@forEach
layers[succ]?.let { (l, sDep) ->
println(" - Successor $succ already had layer $l (might be increased, along with its dependents)")
dep += sDep
val delta = succLayer - l
if (delta > 0) {
layers[succ] = succLayer to (sDep)
@ -145,27 +274,36 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
?: (succLayer to mutableSetOf())
}
}
} ?: run {
layers[succ] = succLayer to mutableSetOf()
}
println(" - Adding successor to queue: $succ (layer: ${layers[succ]?.first})")
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) }
}
println(" - Assigned layers:")
layers.forEach { (v, p) -> println(" - Vertex $v: layer ${p.first}, dependents: ${p.second}") }
// 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 }
println(" - Have $layerCount layers")
var minOffset = Float.POSITIVE_INFINITY
var maxOffset = Float.NEGATIVE_INFINITY
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
}
@ -178,10 +316,16 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
y
}
println(" - Layer measurements: (height, y) = ${(layerHeights zip layerY)}")
println(" - Layers with nodes:")
for(i in 0 until layerCount) {
val verts = layers.filter { it.value.first == i }.keys
println(" - Layer $i: $verts")
}
// 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
@ -196,14 +340,18 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
}
var currentXZero = 0.0f
disjoint.forEach { sub ->
println(" - Layouting disjoint subgraph: $sub")
// 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()
}
println(" - Initial layered vertices:")
layered.forEachIndexed { idx, list -> println(" - Layer $idx: $list") }
// Break up multi-layer edges with dummy nodes
layered.forEachIndexed { idx, list ->
list.forEach { v ->
@ -217,16 +365,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 +397,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 +407,40 @@ 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>>>>()
val crossings = mutableListOf<Int>()
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 {
println(" - Checking edge between $it and $vBot: ${it.directParentOf(vBot, _graph)} || ${vBot.directParentOf(it, _graph)}")
it.directParentOf(vBot, _graph) || vBot.directParentOf(it, _graph)
}.map { it to vBot }
current.addAll(forVBot)
}
val c = computeCrossings(layered[i - 1], layered[i], current)
crossings.add(c)
println(" - Connections between layer ${i - 1} and $i have $c crossings")
}
// 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 +451,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!! } }
}

View File

@ -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)
}
}

View 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()

View File

@ -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()
}

View 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)
}

View File

@ -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())

View 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()
}
}