diff --git a/build.gradle.kts b/build.gradle.kts index b8aa564..73722ea 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,7 @@ plugins { kotlin("jvm") version "2.2.0" } -group = "be.topl.phoenix-intellij" +group = "com.jaytux.altgraph" version = "1.0-SNAPSHOT" repositories { diff --git a/src/main/kotlin/com/jaytux/altgraph/examples/SimpleCFG.kt b/src/main/kotlin/com/jaytux/altgraph/examples/SimpleCFG.kt new file mode 100644 index 0000000..eae38ec --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/examples/SimpleCFG.kt @@ -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 { + val graph = BaseGraph() + // 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): GraphPane { + 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 + } +} \ 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 index f071b12..df33bf5 100644 --- a/src/main/kotlin/com/jaytux/altgraph/layout/Geometry.kt +++ b/src/main/kotlin/com/jaytux/altgraph/layout/Geometry.kt @@ -18,4 +18,12 @@ data class GSize(var width: Float, var height: Float) * @property x the x coordinate * @property y the y coordinate */ -data class GPoint(var x: Float, var y: Float) \ No newline at end of file +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) +} \ 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 index 12a7211..a0d7ecd 100644 --- a/src/main/kotlin/com/jaytux/altgraph/layout/PseudoForestLayout.kt +++ b/src/main/kotlin/com/jaytux/altgraph/layout/PseudoForestLayout.kt @@ -96,11 +96,13 @@ class PseudoForestLayout( } try { + println("Took lock in ${Exception().stackTrace.first()}") val x = block() _lock.store(false) // unlock after operation return x } finally { + println("Releasing lock in ${Exception().stackTrace.first()}") _lock.store(false) // we can safely unlock } } @@ -166,7 +168,9 @@ class PseudoForestLayout( } override fun compute() { + println("Acquiring lock") locked { + print("Starting layouting") _positions.clear() // Assign a layer to each vertex by traversing depth-first and ignoring back-edges. @@ -178,17 +182,22 @@ class PseudoForestLayout( val layers = mutableMapOf>>() val queue = ArrayDeque>>(roots.size * 2) queue.addAll(roots.map { it to emptySet() }) + println(" - Computing layers from roots: $roots") while (!queue.isEmpty()) { val (vertex, onPath) = queue.removeFirst() val (layer, dep) = layers.getOrPut(vertex) { 0 to mutableSetOf() } + println(" - Visiting $vertex (layer $layer), path=$onPath, deps=$dep") val succLayer = layer + 1 _graph.successors(vertex).forEach { (succ, _) -> + if (succ in onPath) return@forEach dep += succ - if (succ in onPath) return@forEach layers[succ]?.let { (l, sDep) -> + println(" - Successor $succ already had layer $l (might be increased, along with its dependents)") + dep += sDep + val delta = succLayer - l if (delta > 0) { layers[succ] = succLayer to (sDep) @@ -197,17 +206,26 @@ class PseudoForestLayout( ?: (succLayer to mutableSetOf()) } } + } ?: run { + layers[succ] = succLayer to mutableSetOf() } + println(" - Adding successor to queue: $succ (layer: ${layers[succ]?.first})") queue.addLast(succ to onPath + vertex) } + + // ensure dependents are always up to date + layers.values.filter { it.second.contains(vertex) }.forEach { (_, d) -> d.addAll(dep) } } + println(" - Assigned layers:") + layers.forEach { (v, p) -> println(" - Vertex $v: layer ${p.first}, dependents: ${p.second}") } // Cache node sizes val vertexSizes = layers.mapValues { (v, _) -> _vertexSize(v).let { it.fullSize() to it.vCenterInBox() } } // Compute layer y positions (and thus the bounding box height). - val layerCount = layers.maxOf { it.value.first } + val layerCount = layers.maxOf { it.value.first } + 1 val layerHeights = MutableList(layerCount) { 0.0f } + println(" - Have $layerCount layers") var minOffset = Float.POSITIVE_INFINITY var maxOffset = Float.NEGATIVE_INFINITY @@ -230,9 +248,17 @@ class PseudoForestLayout( y } + println(" - Layer measurements: (height, y) = ${(layerHeights zip layerY)}") + println(" - Layers with nodes:") + for(i in 0 until layerCount) { + val verts = layers.filter { it.value.first == i }.keys + println(" - Layer $i: $verts") + } + // Compute disjoint graphs val disjoint = roots.fold(listOf>()) { acc, root -> val reachable = layers[root]?.second?.toMutableSet() ?: return@fold acc + reachable += root val dedup = acc.mapNotNull { other -> val inter = reachable intersect other @@ -248,6 +274,7 @@ class PseudoForestLayout( } var currentXZero = 0.0f disjoint.forEach { sub -> + println(" - Layouting disjoint subgraph: $sub") // Put each vertex in a list by layer val layered = List(layerCount) { layer -> sub.mapNotNull { @@ -256,6 +283,9 @@ class PseudoForestLayout( }.toMutableList() } + println(" - Initial layered vertices:") + layered.forEachIndexed { idx, list -> println(" - Layer $idx: $list") } + // Break up multi-layer edges with dummy nodes layered.forEachIndexed { idx, list -> list.forEach { v -> @@ -275,6 +305,15 @@ class PseudoForestLayout( } val layerWidths = MutableList(layerCount) { -horizontalMargin } // avoid double adding margin on 1st node + + // Layout roots + layered[0].forEach { root -> + root.x.fold({ node -> + val w = vertexSizes[node]!!.first.width + layerWidths[0] += w + horizontalMargin + }) {} + } + // Layer-by-layer, assign x slots (not yet positions) for(i in 1 until layered.size) { // Barycenter heuristic: average of parents' slots @@ -299,11 +338,13 @@ class PseudoForestLayout( // Assign x positions layered.forEachIndexed { idx, layer -> + println(" - Positioning layer $idx") var currentX = currentXZero + (maxWidth - layerWidths[idx]) / 2 layer.forEach { v -> v.x.fold({ node -> val offset = vertexSizes[node]!!.second _positions[node] = GPoint(x = currentX + offset.x, y = layerY[idx] + offset.y) + println(" - Put vertex $node at ${_positions[node]}") currentX += vertexSizes[node]!!.first.width + horizontalMargin }) { /* do nothing */ } } @@ -316,16 +357,16 @@ class PseudoForestLayout( // Compute the bounding box // min x and y are 0 _boundingBox = GSize(currentXZero - disjoinXMargin, layerY.last() + layerHeights.last() / 2 + offset) + println("Done layouting") } + println("Released lock") } override fun location(vertex: V): GPoint { - if(vertex !in _graph.vertices()) throw GraphException.vertexNotFound(vertex) - return locked { - _positions[vertex] ?: run { - compute() - _positions[vertex]!! - } + if (vertex !in _graph.vertices()) throw GraphException.vertexNotFound(vertex) + return _positions[vertex] ?: run { + compute() + _positions[vertex] ?: throw IllegalArgumentException("Vertex $vertex was not layout-ed: $_positions") } } diff --git a/src/main/kotlin/com/jaytux/altgraph/layout/SumType.kt b/src/main/kotlin/com/jaytux/altgraph/layout/SumType.kt index ad1f44b..4d3231f 100644 --- a/src/main/kotlin/com/jaytux/altgraph/layout/SumType.kt +++ b/src/main/kotlin/com/jaytux/altgraph/layout/SumType.kt @@ -59,6 +59,8 @@ sealed class SumType { is SumT2 -> onT2(value) } + override fun toString(): String = fold({ it.toString() }) { it.toString() } + companion object { /** * Constructs a [SumType] holding a `T1`. diff --git a/src/main/kotlin/com/jaytux/altgraph/swing/DefaultEdge.kt b/src/main/kotlin/com/jaytux/altgraph/swing/DefaultEdge.kt new file mode 100644 index 0000000..1054e45 --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/swing/DefaultEdge.kt @@ -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( + var delta: Float = 0.2f, + var arrowLen: Int = 10, + var color: Color = Color.BLACK, + var arrowAngle: Double = Math.PI / 6.0 +) : IEdgeRenderer { + override fun drawEdge(g: Graphics2D, from: Point, to: Point, meta: E, offsetFrom: Float, offsetTo: Float) { + val g2 = g.create() as Graphics2D + g2.color = color + + 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 defaultEdgeRenderer(): IEdgeRenderer = QuadraticEdge() \ No newline at end of file diff --git a/src/main/kotlin/com/jaytux/altgraph/swing/DefaultVertexComponent.kt b/src/main/kotlin/com/jaytux/altgraph/swing/DefaultVertexComponent.kt new file mode 100644 index 0000000..66d91f1 --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/swing/DefaultVertexComponent.kt @@ -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 defaultRenderer( + toString: (V) -> String = { it.toString() } +) : IVertexRenderer = IVertexRenderer { v: V -> + DefaultVertexComponent(toString(v)).asRenderData() +} \ No newline at end of file diff --git a/src/main/kotlin/com/jaytux/altgraph/swing/Functional.kt b/src/main/kotlin/com/jaytux/altgraph/swing/Functional.kt new file mode 100644 index 0000000..b670b51 --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/swing/Functional.kt @@ -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 { + data class RenderData( + val drawer: IDrawable, + val arrowTarget: Point, + val arrowTargetOffset: Float + ) + fun getDrawable(v: V): RenderData +} + +fun interface IEdgeRenderer { + fun drawEdge(g: Graphics2D, from: Point, to: Point, meta: E, offsetFrom: Float, offsetTo: Float) +} \ No newline at end of file diff --git a/src/main/kotlin/com/jaytux/altgraph/swing/GraphPane.kt b/src/main/kotlin/com/jaytux/altgraph/swing/GraphPane.kt new file mode 100644 index 0000000..3431ee6 --- /dev/null +++ b/src/main/kotlin/com/jaytux/altgraph/swing/GraphPane.kt @@ -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( + val graph: IGraph, + layout: (GraphPane, IGraph) -> ILayout +) : JPanel() +{ + private val _delta = AffineTransform() + private var _zoom = 1.0 + private var _panX = 0.0 + private var _panY = 0.0 + + private val _layout: ILayout + private var _renderer: IVertexRenderer = defaultRenderer() + private var _edgeRenderer: IEdgeRenderer = defaultEdgeRenderer() + private val _vertices = mutableMapOf() + + 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) { + _renderer = renderer + _vertices.clear() + repaint() + } + + fun setEdgeRenderer(renderer: IEdgeRenderer) { + _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() + } +} \ No newline at end of file