Documentation

This commit is contained in:
2025-08-22 21:21:18 +02:00
parent 64560b172b
commit 4a5db84148
7 changed files with 398 additions and 10 deletions

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

@ -1,4 +1,21 @@
package com.jaytux.altgraph.layout package com.jaytux.altgraph.layout
/**
* 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) data class GSize(var width: Float, var height: Float)
/**
* 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) data class GPoint(var x: Float, var y: Float)

View File

@ -2,12 +2,76 @@ package com.jaytux.altgraph.layout
import com.jaytux.altgraph.core.IGraph import com.jaytux.altgraph.core.IGraph
/**
* 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> { 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()
/**
* 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 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) 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)
/**
* 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 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,14 +8,56 @@ import kotlin.concurrent.atomics.AtomicBoolean
import kotlin.concurrent.atomics.ExperimentalAtomicApi import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.math.max import kotlin.math.max
/**
* 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 vertexSize a function that returns the size of a vertex, including its label offset
*
* @see ILayout
*/
class PseudoForestLayout<V, E>( 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,
vertexSize: (V) -> VertexSize vertexSize: (V) -> VertexSize
) : ILayout<V, E> { ) : ILayout<V, E>
{
/**
* 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) { 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 fun fullSize(): GSize { // TODO: check the math here
val minX = 0 + labelOffset.x val minX = 0 + labelOffset.x
val minY = 0 + labelOffset.y val minY = 0 + labelOffset.y
@ -27,6 +69,11 @@ class PseudoForestLayout<V, E>(
) )
} }
/**
* 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 fun vCenterInBox(): GPoint { // TODO: check the math here
return GPoint( return GPoint(
x = labelOffset.x + vertex.width / 2, x = labelOffset.x + vertex.width / 2,
@ -60,6 +107,12 @@ class PseudoForestLayout<V, E>(
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 } }
/**
* 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 } } fun setVertexSize(vertexSize: (V) -> VertexSize) { locked { _vertexSize = vertexSize } }
// Either a vertex, or a dummy node to break up multi-layer-spanning edges // Either a vertex, or a dummy node to break up multi-layer-spanning edges
@ -268,14 +321,16 @@ class PseudoForestLayout<V, E>(
override fun location(vertex: V): GPoint { 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 locked {
_positions[vertex] ?: run {
compute() compute()
_positions[vertex]!! _positions[vertex]!!
} }
} }
}
override fun freezeAt(vertex: V, point: GPoint) = 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(): GSize = _boundingBox ?: run { compute(); _boundingBox!! } override fun boundingBox(): GSize = locked { _boundingBox ?: run { compute(); _boundingBox!! } }
} }

View File

@ -1,25 +1,78 @@
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)
} }
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)
} }
} }