Compare commits

...

7 Commits

Author SHA1 Message Date
b00dc96f5b Improved layouting (minimize #crossings) 2025-09-09 19:07:31 +02:00
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
16 changed files with 1106 additions and 119 deletions

View File

@ -1,15 +1,19 @@
plugins { plugins {
kotlin("jvm") version "2.2.0" kotlin("jvm") version "2.2.0"
id("org.jetbrains.dokka") version "2.0.0"
`java-library`
`maven-publish`
} }
group = "be.topl.phoenix-intellij" group = "com.github.jaytux"
version = "1.0-SNAPSHOT" version = "1.0-alpha"
repositories { repositories {
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
api(kotlin("stdlib"))
testImplementation(kotlin("test")) testImplementation(kotlin("test"))
} }
@ -19,3 +23,22 @@ tasks.test {
kotlin { kotlin {
jvmToolchain(21) 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 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> { open class BaseGraph<V, E> : IMutableGraph<V, E> {
private val _vertices = mutableMapOf<V, Boolean>() private val _vertices = mutableMapOf<V, Boolean>()
// [from] -> {e: exists v s.t. e = (from, v)} // [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) } 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() fun immutable(): IGraph<V, E> = BaseGraphImmutable()
override fun successors(vertex: V): Map<V, E> = override fun successors(vertex: V): Map<V, E> =

View File

@ -1,42 +1,221 @@
package com.jaytux.altgraph.core 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> { interface IGraph<V, E> {
/**
* Gets all the vertices in the graph.
*
* @return a set of all vertices in the graph
*/
fun vertices(): Set<V> 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>> 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> 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> = fun successors(vertex: V): Map<V, E> =
edges().filter { it.value.first == vertex } edges().filter { it.value.first == vertex }
.map { (edge, pair) -> pair.second to edge }.toMap() .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> = fun predecessors(vertex: V): Map<V, E> =
edges().filter { it.value.second == vertex } edges().filter { it.value.second == vertex }
.map { (edge, pair) -> pair.first to edge }.toMap() .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 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> { 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 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) 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 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) 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) fun removeEdge(edge: E)
} }
/**
* Exception thrown when a graph operation fails.
*
* @param message the exception message
*/
class GraphException(message: String) : RuntimeException(message) { class GraphException(message: String) : RuntimeException(message) {
companion object { 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) = fun <V> vertexAlreadyExists(vertex: V) =
GraphException("Vertex '$vertex' already exists in this graph.") 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) = fun <E> edgeAlreadyExists(edge: E) =
GraphException("Edge '$edge' already exists in this graph.") 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) = fun <V> edgeBetweenAlreadyExists(from: V, to: V) =
GraphException("Edge from '$from' to '$to' already exists in this graph.") 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) = fun <V> vertexNotFound(vertex: V) =
GraphException("Vertex '$vertex' not found in this graph.") 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) = fun <V> noEdgeFound(from: V, to: V) =
GraphException("No edge found from '$from' to '$to' in this graph.") 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) = fun <E> edgeNotFound(edge: E) =
GraphException("Edge '$edge' not found in this graph.") 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 package com.jaytux.altgraph.layout
interface ISize<S : ISize<S>> { /**
fun width(): Float * A simple 2D size.
fun height(): Float *
fun copy(width: Float, height: Float): S * 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 * A simple 2D point.
fun y(): Float *
fun copy(x: Float, y: Float): P * 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 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> fun graph(): IGraph<V, E>
/**
* Sets the graph to layout.
*
* @param graph the graph
*/
fun setGraph(graph: IGraph<V, E>) 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 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 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,107 @@ import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.math.max 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>, graph: IGraph<V, E>,
var horizontalMargin: Float, var horizontalMargin: Float,
var disjoinXMargin: Float, var disjoinXMargin: Float,
var interLayer: Float, var interLayer: Float,
val pointZero: P, val sizeZero: S, val ignoreInLayout: (E, LayoutPhase) -> Boolean = { _, _ -> false },
vertexSize: (V) -> VertexSize<P, S> vertexSize: (V) -> VertexSize
) : ILayout<V, E, P, S> { ) : ILayout<V, E>
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() * An enum representing the different phases of the layout process where edges can be ignored.
val minY = 0 + labelOffset.y() * - [LAYERING]: during the layering phase, where back-edges are ignored to determine layers.
val maxX = vertex.width() + labelOffset.x() + labelSize.width() * - [DISJOINTS]: during the disjoint graph computation phase, where edges connecting disjoint subgraphs can be ignored.
val maxY = vertex.height() + labelOffset.y() + labelSize.height() * - [SLOT_ASSIGNMENT]: during the slot assignment phase, where certain edges may be ignored to optimize layout.
return vertex.copy( */
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, width = maxX - minX,
height = maxY - minY height = maxY - minY
) )
} }
fun vCenterInBox(): P { // TODO: check the math here /**
return labelOffset.copy( * Calculates the center point of the vertex within its bounding box.
x = labelOffset.x() + vertex.width() / 2, *
y = labelOffset.y() + vertex.height() / 2 * @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 var _graph: IGraph<V, E> = graph
private val _positions = mutableMapOf<V, P>() private val _positions = mutableMapOf<V, GPoint>()
private var _vertexSize: (V) -> VertexSize<P, S> = vertexSize private var _vertexSize: (V) -> VertexSize = vertexSize
private var _boundingBox: S? = null private var _boundingBox: GSize? = null
private var _repeat: Int = 3
@OptIn(ExperimentalAtomicApi::class) @OptIn(ExperimentalAtomicApi::class)
private var _lock: AtomicBoolean = AtomicBoolean(false) private var _lock: AtomicBoolean = AtomicBoolean(false)
@ -50,77 +120,138 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
} }
try { try {
println("Took lock in ${Exception().stackTrace.first()}")
val x = block() val x = block()
_lock.store(false) // unlock after operation _lock.store(false) // unlock after operation
return x return x
} }
finally { finally {
println("Releasing lock in ${Exception().stackTrace.first()}")
_lock.store(false) // we can safely unlock _lock.store(false) // we can safely unlock
} }
} }
override fun graph(): IGraph<V, E> = _graph override fun graph(): IGraph<V, E> = _graph
override fun setGraph(graph: IGraph<V, E>) { locked { _graph = 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>( * Gets the number of iterations the layout algorithm will perform to optimize the layout.
var from: LayeredVertex<V>, *
var to: LayeredVertex<V> * @return the number of iterations
) */
private data class LayeredVertex<V>( fun getIterationCount(): Int = _repeat
val x: SumType<V, Connector<V>> /**
) { * 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: 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 } other.x.fold({ it2 -> it1 == it2 }) { false }
}) { it1 -> }) { 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: Vert<V>, graph: IGraph<V, *>): Boolean = x.fold({ it1 ->
fun directParentOf(other: LayeredVertex<V>, graph: IGraph<V, *>): Boolean = other.x.fold({ it2 -> graph.xToY(it1, it2) != null }) { it2 ->
x.fold({ xx -> it2.from?.x == it1
// 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)
} }
}) { it1 ->
it1.to!!.same(other)
}
} }
private data class PreConnector<V>(
var x: SumType<LayeredVertex<V>, PreConnector<V>>?, private fun buildChain(from: V, to: V, layerF: Int, layerT: Int): List<Vert<V>> {
var y: SumType<LayeredVertex<V>, PreConnector<V>>? val first = Vert(from)
) val last = Vert(to)
private fun realVertex(v: V) = LayeredVertex(v.sum1()) val chain = List(layerT - layerF - 1) { Vert(Conn<V>(null, null)) }
private fun buildChain(from: V, to: V, layerF: Int, layerT: Int): List<LayeredVertex<V>> {
val chain = mutableListOf(LayeredVertex(Connector(LayeredVertex(from), LayeredVertex(to)))) chain.forEachIndexed { i, v ->
while(chain.size < layerT - layerF - 1) { if(i == 0) (v.x.asT2).from = first
val last = chain.last() // last is always Connector<V>, and last.to is always V (== to) else (v.x.asT2).from = chain[i - 1]
val lastX = (last.x as SumType.SumT2<Connector<V>>).value
val next = LayeredVertex(Connector(last, lastX.to)) if(i == chain.size - 1) (v.x.asT2).to = last
lastX.to = next // reconnect else (v.x.asT2).to = chain[i + 1]
chain += next
} }
return 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
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() { override fun compute() {
println("Acquiring lock")
locked { locked {
print("Starting layouting")
_positions.clear() _positions.clear()
// Assign a layer to each vertex by traversing depth-first and ignoring back-edges. // Assign a layer to each vertex by traversing depth-first and ignoring back-edges.
val roots = _graph.roots() val roots = _graph.roots()
if (roots.isEmpty()) { // Only reachable nodes matter. if (roots.isEmpty()) { // Only reachable nodes matter.
_boundingBox = sizeZero _boundingBox = GSize(0.0f, 0.0f)
return return
} }
val layers = mutableMapOf<V, Pair<Int, MutableSet<V>>>() 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 (layer, dep) = layers.getOrPut(vertex) { 0 to mutableSetOf() }
val succLayer = layer + 1 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 dep += succ
if (succ in onPath) return@forEach
layers[succ]?.let { (l, sDep) -> layers[succ]?.let { (l, sDep) ->
dep += sDep
val delta = succLayer - l val delta = succLayer - l
if (delta > 0) { if (delta > 0) {
layers[succ] = succLayer to (sDep) layers[succ] = succLayer to (sDep)
@ -145,16 +281,21 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
?: (succLayer to mutableSetOf()) ?: (succLayer to mutableSetOf())
} }
} }
} ?: run {
layers[succ] = succLayer to mutableSetOf()
} }
queue.addLast(succ to onPath + vertex) 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 // Cache node sizes
val vertexSizes = layers.mapValues { (v, _) -> _vertexSize(v).let { it.fullSize() to it.vCenterInBox() } } val vertexSizes = layers.mapValues { (v, _) -> _vertexSize(v).let { it.fullSize() to it.vCenterInBox() } }
// Compute layer y positions (and thus the bounding box height). // 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 } val layerHeights = MutableList(layerCount) { 0.0f }
var minOffset = Float.POSITIVE_INFINITY var minOffset = Float.POSITIVE_INFINITY
@ -162,10 +303,10 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
layers.forEach { (vertex, pair) -> layers.forEach { (vertex, pair) ->
val (layer, _) = pair val (layer, _) = pair
val size = vertexSizes[vertex]!! val size = vertexSizes[vertex]!!
layerHeights[layer] = max(layerHeights[layer], size.first.height()) layerHeights[layer] = max(layerHeights[layer], size.first.height)
if(layer == 0) { if(layer == 0) {
// Take into account vertex bounding box offset // Take into account vertex bounding box offset
val delta = size.second.y() val delta = size.second.y
if(delta < minOffset) minOffset = delta if(delta < minOffset) minOffset = delta
if(delta > maxOffset) maxOffset = delta if(delta > maxOffset) maxOffset = delta
} }
@ -180,8 +321,7 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
// Compute disjoint graphs // Compute disjoint graphs
val disjoint = roots.fold(listOf<Set<V>>()) { acc, root -> 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 dedup = acc.mapNotNull { other ->
val inter = reachable intersect other val inter = reachable intersect other
if(inter.isEmpty()) other // fully disjoint -> keep 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 // Put each vertex in a list by layer
val layered = List(layerCount) { layer -> val layered = List(layerCount) { layer ->
sub.mapNotNull { sub.mapNotNull {
if(layers[it]?.first == layer) realVertex(it) if(layers[it]?.first == layer) Vert(it)
else null else null
}.toMutableList() }.toMutableList()
} }
@ -217,16 +357,31 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
layered[idx + offset + 1] += dummy 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 }) {} // do nothing on dummy nodes
} }
} }
val layerWidths = MutableList(layerCount) { -horizontalMargin } // avoid double adding margin on 1st node 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) // Layer-by-layer, assign x slots (not yet positions)
for(i in 1 until layered.size) { for(i in 1 until layered.size) {
// Barycenter heuristic: average of parents' slots // 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 } val parents = layered[i - 1].mapIndexedNotNull { idx, p -> if(p.directParentOf(v, _graph)) idx.toFloat() else null }
parents.sum() / parents.size parents.sum() / parents.size
} }
@ -234,7 +389,7 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
layered[i].forEach { v -> layered[i].forEach { v ->
v.x.fold({ node -> v.x.fold({ node ->
val w = vertexSizes[node]!!.first.width() val w = vertexSizes[node]!!.first.width
layerWidths[i] += w + horizontalMargin layerWidths[i] += w + horizontalMargin
}) { /* do nothing */ } }) { /* do nothing */ }
} }
@ -244,15 +399,59 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
val maxWidth = layerWidths.max() val maxWidth = layerWidths.max()
// TODO: do some reorderings to minimize #crossings? // 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 // Assign x positions
layered.forEachIndexed { idx, layer -> layered.forEachIndexed { idx, layer ->
println(" - Positioning layer $idx")
var currentX = currentXZero + (maxWidth - layerWidths[idx]) / 2 var currentX = currentXZero + (maxWidth - layerWidths[idx]) / 2
layer.forEach { v -> layer.forEach { v ->
v.x.fold({ node -> v.x.fold({ node ->
val offset = vertexSizes[node]!!.second val offset = vertexSizes[node]!!.second
_positions[node] = pointZero.copy(x = currentX + offset.x(), y = layerY[idx] + offset.y()) _positions[node] = GPoint(x = currentX + offset.x, y = layerY[idx] + offset.y)
currentX += vertexSizes[node]!!.first.width() + horizontalMargin println(" - Put vertex $node at ${_positions[node]}")
currentX += vertexSizes[node]!!.first.width + horizontalMargin
}) { /* do nothing */ } }) { /* do nothing */ }
} }
} }
@ -263,20 +462,22 @@ class PseudoForestLayout<V, E, P : IPoint<P>, S : ISize<S>>(
// Compute the bounding box // Compute the bounding box
// min x and y are 0 // 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 { override fun location(vertex: V): GPoint {
if(vertex !in _graph.vertices()) throw GraphException.vertexNotFound(vertex) if (vertex !in _graph.vertices()) throw GraphException.vertexNotFound(vertex)
return _positions[vertex] ?: run { return _positions[vertex] ?: run {
compute() 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 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 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> { 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>() { class SumT1<out T1>(val value: T1) : SumType<T1, Nothing>() {
override fun equals(other: Any?): Boolean = 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() 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>() { class SumT2<out T2>(val value: T2) : SumType<Nothing, T2>() {
override fun equals(other: Any?): Boolean = 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() 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) { fun <R> fold(onT1: (T1) -> R, onT2: (T2) -> R): R = when(this) {
is SumT1 -> onT1(value) is SumT1 -> onT1(value)
is SumT2 -> onT2(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 { 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) 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) fun <T> T.sum2(): SumType<Nothing, T> = SumT2(this)
} }
} }

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

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 package com.jaytux.altgraph.swing
import com.jaytux.altgraph.layout.IPoint import com.jaytux.altgraph.layout.GPoint
import com.jaytux.altgraph.layout.ISize import com.jaytux.altgraph.layout.GSize
import java.awt.Dimension import java.awt.Dimension
import java.awt.Point import java.awt.Point
class GSize(val size: Dimension) : ISize<GSize> { fun GSize.swing() = Dimension(width.toInt(), height.toInt())
override fun width(): Float = size.width.toFloat() fun GPoint.swing() = Point(x.toInt(), y.toInt())
override fun height(): Float = size.height.toFloat()
override fun copy(width: Float, height: Float): GSize =
GSize(Dimension(width.toInt(), height.toInt()))
override fun hashCode(): Int = size.hashCode() fun Dimension.graph() = GSize(width.toFloat(), height.toFloat())
override fun equals(other: Any?): Boolean = other is GSize && size == other.size fun Point.graph() = GPoint(x.toFloat(), y.toFloat())
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"
}

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