diff --git a/src/main/kotlin/com/jaytux/altgraph/core/BaseGraph.kt b/src/main/kotlin/com/jaytux/altgraph/core/BaseGraph.kt new file mode 100644 index 0000000..b8b69db --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/core/BaseGraph.kt @@ -0,0 +1,100 @@ +package com.jaytux.altgraph.core + +open class BaseGraph : IMutableGraph { + private val _vertices = mutableMapOf() + // [from] -> {e: exists v s.t. e = (from, v)} + private val _existing = mutableMapOf>() + // [edge] -> (from, to) + private val _edges = mutableMapOf>() + + private inner class BaseGraphImmutable : IGraph { + override fun vertices(): Set = this@BaseGraph.vertices() + override fun edges(): Map> = this@BaseGraph.edges() + override fun roots(): Set = this@BaseGraph.roots() + } + + override fun vertices(): Set = _vertices.keys + override fun edges(): Map> = _edges + override fun roots(): Set = _vertices.filter { it.value }.keys + + override fun addVertex(vertex: V, isRoot: Boolean): V { + if(vertex in _vertices) throw GraphException.vertexAlreadyExists(vertex) + _vertices += vertex to isRoot + return vertex + } + + override fun removeVertex(vertex: V) { + if(vertex !in _vertices) + throw GraphException.vertexNotFound(vertex) + + _existing[vertex]?.forEach { edge -> + _edges.remove(edge) + } + _existing.remove(vertex) + + _edges.filter { (_, v) -> v.second == vertex }.forEach { (k, v) -> + _edges.remove(k) + _existing[v.first]?.remove(k) + } + + _vertices.remove(vertex) + } + + override fun connect(from: V, to: V, edge: E): E { + if(from !in _vertices) throw GraphException.vertexNotFound(from) + if(to !in _vertices) throw GraphException.vertexNotFound(to) + if(edge in _edges) throw GraphException.edgeAlreadyExists(edge) + + if(_existing[from]?.contains(edge) == true) + throw GraphException.edgeBetweenAlreadyExists(from, to) + + _edges[edge] = Pair(from, to) + _existing.getOrPut(from) { mutableSetOf() } += edge + + return edge + } + + override fun disconnect(from: V, to: V) { + val edge = _existing[from]?.firstOrNull { edge -> _edges[edge]!!.second == to } + ?: throw GraphException.noEdgeFound(from, to) + _edges.remove(edge) + _existing[from]?.remove(edge) + if (_existing[from]?.isEmpty() == true) { _existing.remove(to) } + } + + override fun removeEdge(edge: E) { + if(edge !in _edges) throw GraphException.edgeNotFound(edge) + + val (from, to) = _edges.remove(edge)!! + _existing[from]?.remove(edge) + if (_existing[from]?.isEmpty() == true) { _existing.remove(from) } + } + + fun immutable(): IGraph = BaseGraphImmutable() + + override fun successors(vertex: V): Map = + _existing[vertex]?.associate { edge -> _edges[edge]!!.second to edge } ?: emptyMap() +} + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/kotlin/com/jaytux/altgraph/core/IGraph.kt b/src/main/kotlin/com/jaytux/altgraph/core/IGraph.kt new file mode 100644 index 0000000..ccd4541 --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/core/IGraph.kt @@ -0,0 +1,43 @@ +package com.jaytux.altgraph.core + +interface IGraph { + fun vertices(): Set + fun edges(): Map> + fun roots(): Set + + fun successors(vertex: V): Map = + edges().filter { it.value.first == vertex } + .map { (edge, pair) -> pair.second to edge }.toMap() + + fun predecessors(vertex: V): Map = + edges().filter { it.value.second == vertex } + .map { (edge, pair) -> pair.first to edge }.toMap() + + fun xToY(x: V, y: V): E? = edges().entries.firstOrNull { it.value == x to y }?.key +} + +interface IMutableGraph : IGraph { + fun addVertex(vertex: V, isRoot: Boolean = false): V + fun removeVertex(vertex: V) + fun connect(from: V, to: V, edge: E): E + fun disconnect(from: V, to: V) + fun removeEdge(edge: E) +} + +class GraphException(message: String) : RuntimeException(message) { + companion object { + fun vertexAlreadyExists(vertex: V) = + GraphException("Vertex '$vertex' already exists in this graph.") + fun edgeAlreadyExists(edge: E) = + GraphException("Edge '$edge' already exists in this graph.") + fun edgeBetweenAlreadyExists(from: V, to: V) = + GraphException("Edge from '$from' to '$to' already exists in this graph.") + + fun vertexNotFound(vertex: V) = + GraphException("Vertex '$vertex' not found in this graph.") + fun noEdgeFound(from: V, to: V) = + GraphException("No edge found from '$from' to '$to' in this graph.") + fun edgeNotFound(edge: E) = + GraphException("Edge '$edge' not found in this graph.") + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jaytux/altgraph/layout/Geometry.kt b/src/main/kotlin/com/jaytux/altgraph/layout/Geometry.kt new file mode 100644 index 0000000..1ef44d1 --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/layout/Geometry.kt @@ -0,0 +1,13 @@ +package com.jaytux.altgraph.layout + +interface ISize> { + fun width(): Float + fun height(): Float + fun copy(width: Float, height: Float): S +} + +interface IPoint

> { + fun x(): Float + fun y(): Float + fun copy(x: Float, y: Float): P +} \ No newline at end of file diff --git a/src/main/kotlin/com/jaytux/altgraph/layout/ILayout.kt b/src/main/kotlin/com/jaytux/altgraph/layout/ILayout.kt new file mode 100644 index 0000000..3b2db32 --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/layout/ILayout.kt @@ -0,0 +1,13 @@ +package com.jaytux.altgraph.layout + +import com.jaytux.altgraph.core.IGraph + +interface ILayout, S : ISize> { + fun graph(): IGraph + fun setGraph(graph: IGraph) + fun compute() + fun location(vertex: V): P + fun freezeAt(vertex: V, point: P) + fun unfreeze(vertex: V) + fun boundingBox(): S +} \ No newline at end of file diff --git a/src/main/kotlin/com/jaytux/altgraph/layout/MutablePair.kt b/src/main/kotlin/com/jaytux/altgraph/layout/MutablePair.kt new file mode 100644 index 0000000..12ddc05 --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/layout/MutablePair.kt @@ -0,0 +1,3 @@ +package com.jaytux.altgraph.layout + +data class MutablePair(var x: T1, var y: T2) \ No newline at end of file diff --git a/src/main/kotlin/com/jaytux/altgraph/layout/PseudoForestLayout.kt b/src/main/kotlin/com/jaytux/altgraph/layout/PseudoForestLayout.kt new file mode 100644 index 0000000..648d2c4 --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/layout/PseudoForestLayout.kt @@ -0,0 +1,282 @@ +package com.jaytux.altgraph.layout + +import com.jaytux.altgraph.core.GraphException +import com.jaytux.altgraph.core.IGraph +import com.jaytux.altgraph.layout.SumType.Companion.sum1 +import com.jaytux.altgraph.layout.SumType.Companion.sum2 +import kotlin.concurrent.atomics.AtomicBoolean +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.math.max + +class PseudoForestLayout, S : ISize>( + graph: IGraph, + var horizontalMargin: Float, + var disjoinXMargin: Float, + var interLayer: Float, + val pointZero: P, val sizeZero: S, + vertexSize: (V) -> VertexSize +) : ILayout { + data class VertexSize

, S : ISize>(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( + 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 + ) + } + } + private var _graph: IGraph = graph + private val _positions = mutableMapOf() + private var _vertexSize: (V) -> VertexSize = vertexSize + private var _boundingBox: S? = null + + @OptIn(ExperimentalAtomicApi::class) + private var _lock: AtomicBoolean = AtomicBoolean(false) + + @OptIn(ExperimentalAtomicApi::class) + private inline fun locked(block: () -> R): R { + while (!_lock.compareAndSet(expectedValue = false, newValue = true)) { + // Do a big ol' spinny-spin wait + } + + try { + val x = block() + _lock.store(false) // unlock after operation + return x + } + finally { + _lock.store(false) // we can safely unlock + } + } + + override fun graph(): IGraph = _graph + override fun setGraph(graph: IGraph) { locked { _graph = graph } } + fun setVertexSize(vertexSize: (V) -> VertexSize) { locked { _vertexSize = vertexSize } } + + // Either a vertex, or a dummy node to break up multi-layer-spanning edges + private data class Connector( + var from: LayeredVertex, + var to: LayeredVertex + ) + private data class LayeredVertex( + val x: SumType> + ) { + constructor(x: V): this(x.sum1()) + constructor(x: Connector): this(x.sum2()) + + fun same(other: LayeredVertex) = x.fold({ it1 -> + other.x.fold({ it2 -> it1 == it2 }) { false } + }) { it1 -> + other.x.fold({ false }) { it2 -> it1.from == it2.from && it1.to == it2.to } + } + + // true is this is a direct parent of the other vertex/connector + fun directParentOf(other: LayeredVertex, graph: IGraph): Boolean = + x.fold({ xx -> + // x is a vertex + other.x.fold({ + // other is a vertex + graph.xToY(xx, it) != null + }) { + // other is a connector + it.from.same(this) + } + }) { xx -> + // x is a connector -> we need to connect to other + xx.to.same(other) + } + } + private data class PreConnector( + var x: SumType, PreConnector>?, + var y: SumType, PreConnector>? + ) + private fun realVertex(v: V) = LayeredVertex(v.sum1()) + private fun buildChain(from: V, to: V, layerF: Int, layerT: Int): List> { + val chain = mutableListOf(LayeredVertex(Connector(LayeredVertex(from), LayeredVertex(to)))) + while(chain.size < layerT - layerF - 1) { + val last = chain.last() // last is always Connector, and last.to is always V (== to) + val lastX = (last.x as SumType.SumT2>).value + val next = LayeredVertex(Connector(last, lastX.to)) + lastX.to = next // reconnect + chain += next + } + return chain + } + + override fun compute() { + locked { + _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 + return + } + val layers = mutableMapOf>>() + val queue = ArrayDeque>>(roots.size * 2) + queue.addAll(roots.map { it to emptySet() }) + + while (!queue.isEmpty()) { + val (vertex, onPath) = queue.removeFirst() + val (layer, dep) = layers.getOrPut(vertex) { 0 to mutableSetOf() } + + val succLayer = layer + 1 + _graph.successors(vertex).forEach { (succ, _) -> + dep += succ + + if (succ in onPath) return@forEach + layers[succ]?.let { (l, sDep) -> + val delta = succLayer - l + if (delta > 0) { + layers[succ] = succLayer to (sDep) + sDep.forEach { dep -> + layers[dep] = layers[dep]?.let { (dl, dd) -> dl + delta to dd } + ?: (succLayer to mutableSetOf()) + } + } + } + queue.addLast(succ to onPath + vertex) + } + } + + // 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 layerHeights = MutableList(layerCount) { 0.0f } + + 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()) + if(layer == 0) { + // Take into account vertex bounding box offset + val delta = size.second.y() + if(delta < minOffset) minOffset = delta + if(delta > maxOffset) maxOffset = delta + } + } + val offset = maxOffset - minOffset + var totalHeight = 0.0f + val layerY = MutableList(layerCount) { idx -> + val y = totalHeight + layerHeights[idx] / 2 + offset + totalHeight += layerHeights[idx] + interLayer + y + } + + // Compute disjoint graphs + val disjoint = roots.fold(listOf>()) { acc, root -> + val reachable = layers[root]?.second?.toMutableSet() ?: return@fold acc + + val dedup = acc.mapNotNull { other -> + val inter = reachable intersect other + if(inter.isEmpty()) other // fully disjoint -> keep + else { // not disjoint -> merge and remove + reachable.addAll(other) + null + } + }.toMutableList() + + dedup.add(reachable) + dedup + } + var currentXZero = 0.0f + disjoint.forEach { sub -> + // Put each vertex in a list by layer + val layered = List(layerCount) { layer -> + sub.mapNotNull { + if(layers[it]?.first == layer) realVertex(it) + else null + }.toMutableList() + } + + // Break up multi-layer edges with dummy nodes + layered.forEachIndexed { idx, list -> + list.forEach { v -> + v.x.fold({ node -> + val successors = _graph.successors(node) + successors.forEach { (other, _) -> + val otherLayer = layers[other]?.first ?: return@forEach + if(otherLayer > idx + 1) { + val chain = buildChain(node, other, idx, otherLayer) + chain.forEachIndexed { offset, dummy -> + layered[idx + offset + 1] += dummy + } + } + } + }) {} // do nothing on dummy nodes + } + } + + val layerWidths = MutableList(layerCount) { -horizontalMargin } // avoid double adding margin on 1st node + // 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 -> + val parents = layered[i - 1].mapIndexedNotNull { idx, p -> if(p.directParentOf(v, _graph)) idx.toFloat() else null } + parents.sum() / parents.size + } + layered[i].sortBy { heuristic(it) } + + layered[i].forEach { v -> + v.x.fold({ node -> + val w = vertexSizes[node]!!.first.width() + layerWidths[i] += w + horizontalMargin + }) { /* do nothing */ } + } + + // TODO: try to do some swaps in order to minimize #crossings? + } + val maxWidth = layerWidths.max() + + // TODO: do some reorderings to minimize #crossings? + + // Assign x positions + layered.forEachIndexed { idx, layer -> + 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 + }) { /* do nothing */ } + } + } + + // Update X-zero for the next disjoint graph + currentXZero += maxWidth + disjoinXMargin + } + + // Compute the bounding box + // min x and y are 0 + _boundingBox = sizeZero.copy(currentXZero - disjoinXMargin, layerY.last() + layerHeights.last() / 2 + offset) + } + } + + override fun location(vertex: V): P { + if(vertex !in _graph.vertices()) throw GraphException.vertexNotFound(vertex) + return _positions[vertex] ?: run { + compute() + _positions[vertex]!! + } + } + + override fun freezeAt(vertex: V, point: P) = 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!! } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jaytux/altgraph/layout/SumType.kt b/src/main/kotlin/com/jaytux/altgraph/layout/SumType.kt new file mode 100644 index 0000000..e501e11 --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/layout/SumType.kt @@ -0,0 +1,25 @@ +package com.jaytux.altgraph.layout + +sealed class SumType { + class SumT1(val value: T1) : SumType() { + override fun equals(other: Any?): Boolean = + other is SumT1<*> && value == other.value + override fun hashCode(): Int = value.hashCode() + } + + class SumT2(val value: T2) : SumType() { + override fun equals(other: Any?): Boolean = + other is SumT2<*> && value == other.value + override fun hashCode(): Int = value.hashCode() + } + + fun fold(onT1: (T1) -> R, onT2: (T2) -> R): R = when(this) { + is SumT1 -> onT1(value) + is SumT2 -> onT2(value) + } + + companion object { + fun T.sum1(): SumType = SumT1(this) + fun T.sum2(): SumType = SumT2(this) + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jaytux/altgraph/swing/GeometryWrappers.kt b/src/main/kotlin/com/jaytux/altgraph/swing/GeometryWrappers.kt new file mode 100644 index 0000000..2e23085 --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/swing/GeometryWrappers.kt @@ -0,0 +1,29 @@ +package com.jaytux.altgraph.swing + +import com.jaytux.altgraph.layout.IPoint +import com.jaytux.altgraph.layout.ISize +import java.awt.Dimension +import java.awt.Point + +class GSize(val size: Dimension) : ISize { + 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())) + + 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 { + 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" +} \ No newline at end of file