Rendering

This commit is contained in:
2025-09-02 14:37:12 +02:00
parent 4a5db84148
commit d460c71a33
9 changed files with 417 additions and 10 deletions

View File

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

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

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

@ -96,11 +96,13 @@ class PseudoForestLayout<V, E>(
}
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<V, E>(
}
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<V, E>(
val layers = mutableMapOf<V, Pair<Int, MutableSet<V>>>()
val queue = ArrayDeque<Pair<V, Set<V>>>(roots.size * 2)
queue.addAll(roots.map { it to emptySet() })
println(" - Computing layers from roots: $roots")
while (!queue.isEmpty()) {
val (vertex, onPath) = queue.removeFirst()
val (layer, dep) = layers.getOrPut(vertex) { 0 to mutableSetOf() }
println(" - Visiting $vertex (layer $layer), path=$onPath, deps=$dep")
val succLayer = layer + 1
_graph.successors(vertex).forEach { (succ, _) ->
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<V, E>(
?: (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<V, E>(
y
}
println(" - Layer measurements: (height, y) = ${(layerHeights zip layerY)}")
println(" - Layers with nodes:")
for(i in 0 until layerCount) {
val verts = layers.filter { it.value.first == i }.keys
println(" - Layer $i: $verts")
}
// Compute disjoint graphs
val disjoint = roots.fold(listOf<Set<V>>()) { acc, root ->
val reachable = layers[root]?.second?.toMutableSet() ?: return@fold acc
reachable += root
val dedup = acc.mapNotNull { other ->
val inter = reachable intersect other
@ -248,6 +274,7 @@ class PseudoForestLayout<V, E>(
}
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<V, E>(
}.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<V, E>(
}
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<V, E>(
// 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<V, E>(
// 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")
}
}

View File

@ -59,6 +59,8 @@ sealed class SumType<out T1, out T2> {
is SumT2 -> onT2(value)
}
override fun toString(): String = fold({ it.toString() }) { it.toString() }
companion object {
/**
* Constructs a [SumType] holding a `T1`.

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: 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
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

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