Skip to content

Commit 85ebdcd

Browse files
authored
Merge pull request #11 from linde9821/feature/radial-placement
Feature/radial placement
2 parents 9d2b239 + 2f8ffd6 commit 85ebdcd

20 files changed

Lines changed: 1117 additions & 501 deletions

File tree

‎README.md‎

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ your nodes; the library computes optimal (x, y) coordinates for every node in th
3232
```kotlin
3333
// build.gradle.kts
3434
dependencies {
35-
implementation("io.github.linde9821:treelayout-kmp:0.2.1")
35+
implementation("io.github.linde9821:treelayout-kmp:0.3.0")
3636
}
3737
```
3838

@@ -158,6 +158,83 @@ val result = WalkerTreeLayout(adapter, config).layout()
158158

159159
Available orientations: `TopToBottom`, `BottomToTop`, `LeftToRight`, `RightToLeft`.
160160

161+
## Radial Layouts
162+
163+
In addition to the linear Walker layout, the library includes two **radial layout algorithms** that arrange nodes in
164+
concentric circles around the root. These are useful when you want a compact, center-outward visualization — common for
165+
org charts, file explorers, and dependency graphs.
166+
167+
### Radial Walker Layout
168+
169+
**Package:** `io.github.linde9821.treelayout.radial.walker`
170+
171+
This algorithm runs the Walker algorithm internally to determine sibling ordering and spacing, then maps the across-axis
172+
(horizontal positions) to angular positions on concentric rings. Each depth level becomes a ring at radius
173+
`depth × layerDistance`.
174+
175+
```kotlin
176+
import io.github.linde9821.treelayout.radial.walker.RadialWalkerTreeLayout
177+
import io.github.linde9821.treelayout.radial.walker.RadialWalkerLayoutConfiguration
178+
179+
val config = RadialWalkerLayoutConfiguration(
180+
layerDistance = 80f,
181+
margin = 0.5f, // radians subtracted from full circle to create a gap
182+
rotation = 0.0f, // angular offset in radians
183+
)
184+
185+
val result = RadialWalkerTreeLayout(
186+
adapter = OrgTreeAdapter(ceo),
187+
configuration = config,
188+
).layout()
189+
190+
result.getPositions().forEach { (node, point) ->
191+
println("${node.name} -> (${point.x}, ${point.y})")
192+
}
193+
```
194+
195+
The constructor accepts an optional `nodeExtentProvider` parameter, which is forwarded to the internal Walker pass for
196+
variable-width node spacing:
197+
198+
```kotlin
199+
val result = RadialWalkerTreeLayout(
200+
adapter = OrgTreeAdapter(ceo),
201+
configuration = config,
202+
nodeExtentProvider = OrgExtentProvider(),
203+
).layout()
204+
```
205+
206+
### Direct Angular Placement Layout
207+
208+
**Package:** `io.github.linde9821.treelayout.radial.angular`
209+
210+
This algorithm recursively partitions angular space among children proportional to their **subtree weight** (total number
211+
of descendants including the child itself). It runs in **O(n)** — one pass to compute weights, one pass to assign
212+
positions. This produces evenly distributed layouts where larger subtrees receive proportionally more angular space.
213+
214+
```kotlin
215+
import io.github.linde9821.treelayout.radial.angular.DirectAngularPlacementLayout
216+
import io.github.linde9821.treelayout.radial.angular.DirectAngularPlacementConfiguration
217+
218+
val config = DirectAngularPlacementConfiguration(
219+
layerDistance = 80f,
220+
rotation = 0.0f, // angular offset in radians
221+
)
222+
223+
val result = DirectAngularPlacementLayout(
224+
adapter = OrgTreeAdapter(ceo),
225+
configuration = config,
226+
).layout()
227+
228+
result.getPositions().forEach { (node, point) ->
229+
println("${node.name} -> (${point.x}, ${point.y})")
230+
}
231+
```
232+
233+
> **When to choose which?**
234+
> Use **Radial Walker** when you want the Walker algorithm's aesthetic guarantees (symmetry, minimal width) projected
235+
> onto a circle. Use **Direct Angular Placement** when you want a simpler, weight-proportional distribution with no
236+
> dependency on the Walker pass.
237+
161238
## API Reference
162239

163240
### `TreeAdapter<T>`
@@ -202,6 +279,38 @@ Available orientations: `TopToBottom`, `BottomToTop`, `LeftToRight`, `RightToLef
202279
Constructor accepts an optional `nodeExtentProvider` parameter. When omitted, nodes are treated as dimensionless points
203280
(backward compatible).
204281

282+
### `RadialWalkerLayoutConfiguration`
283+
284+
| Property | Type | Default | Description |
285+
|-----------------|---------|---------|----------------------------------------------------------------|
286+
| `layerDistance` | `Float` | `1.0f` | Radial distance between concentric depth rings. |
287+
| `margin` | `Float` | `0.0f` | Angular margin (in radians) subtracted from the full circle. |
288+
| `rotation` | `Float` | `0.0f` | Angular offset (in radians) applied to all node positions. |
289+
290+
### `RadialWalkerTreeLayout<T>`
291+
292+
| Method | Description |
293+
|---------------------------------|------------------------------------------------------|
294+
| `layout(): TreeLayoutResult<T>` | Executes the algorithm and returns positioned nodes. |
295+
296+
Constructor parameters: `adapter: TreeAdapter<T>`, `configuration: RadialWalkerLayoutConfiguration`,
297+
`nodeExtentProvider: NodeExtentProvider<T>` (optional, defaults to dimensionless points).
298+
299+
### `DirectAngularPlacementConfiguration`
300+
301+
| Property | Type | Default | Description |
302+
|-----------------|---------|---------|-------------------------------------------------------------|
303+
| `layerDistance` | `Float` | `1.0f` | Radial distance between concentric depth rings. |
304+
| `rotation` | `Float` | `0.0f` | Angular offset (in radians) applied to all node positions. |
305+
306+
### `DirectAngularPlacementLayout<T>`
307+
308+
| Method | Description |
309+
|---------------------------------|------------------------------------------------------|
310+
| `layout(): TreeLayoutResult<T>` | Executes the algorithm and returns positioned nodes. |
311+
312+
Constructor parameters: `adapter: TreeAdapter<T>`, `configuration: DirectAngularPlacementConfiguration`.
313+
205314
### `TreeLayoutResult<T>`
206315

207316
| Method | Description |
@@ -242,7 +351,9 @@ Run the benchmark yourself:
242351
## Running the Samples
243352

244353
The `sample/` module is a Compose Multiplatform application that visualizes a **prefix tree** built from user-provided
245-
words. It demonstrates variable node sizes, all four orientations, and interactive layout controls.
354+
words. It features a dark-themed UI with a **layout type selector** (Walker / RadialWalker / DirectAngular) and
355+
context-sensitive controls for each algorithm — orientation for Walker, margin and rotation for radial layouts. It
356+
demonstrates variable node sizes and interactive layout controls.
246357

247358
### Desktop (JVM)
248359

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package io.github.linde9821.treelayout.radial.angular
2+
3+
/**
4+
* Configuration for the direct angular placement layout algorithm.
5+
*
6+
* @property layerDistance Radial distance between concentric depth rings.
7+
* @property rotation Angular offset (in radians) applied to all nodes.
8+
*/
9+
public data class DirectAngularPlacementConfiguration(
10+
public val layerDistance: Float = 1.0f,
11+
public val rotation: Float = 0.0f,
12+
)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package io.github.linde9821.treelayout.radial.angular
2+
3+
import io.github.linde9821.treelayout.Point
4+
import io.github.linde9821.treelayout.TreeAdapter
5+
import io.github.linde9821.treelayout.TreeLayoutResult
6+
import kotlin.math.PI
7+
import kotlin.math.cos
8+
import kotlin.math.max
9+
import kotlin.math.sin
10+
11+
/**
12+
* A radial tree layout using direct angular partitioning.
13+
*
14+
* Recursively assigns angular ranges to each subtree and places nodes
15+
* at the midpoint of their allocated arc on concentric circles based on depth.
16+
* Each child receives a share of its parent's angular sector proportional to
17+
* its subtree weight (number of descendants including itself).
18+
*
19+
* @param T The node type of the external tree.
20+
* @param adapter Adapter providing tree traversal over the external structure.
21+
* @param configuration Layout spacing parameters.
22+
*/
23+
public class DirectAngularPlacementLayout<T>(
24+
private val adapter: TreeAdapter<T>,
25+
private val configuration: DirectAngularPlacementConfiguration = DirectAngularPlacementConfiguration(),
26+
) {
27+
/**
28+
* Executes the layout algorithm and returns the result.
29+
*/
30+
public fun layout(): TreeLayoutResult<T> {
31+
val weight = HashMap<T, Int>()
32+
computeWeights(adapter.root(), weight)
33+
34+
val positions = HashMap<T, Point>(weight.size)
35+
var maxDepth = 0
36+
37+
fun assignAngles(node: T, depth: Int, start: Float, end: Float) {
38+
maxDepth = max(maxDepth, depth)
39+
40+
if (depth == 0) {
41+
positions[node] = Point(0f, 0f)
42+
} else {
43+
val radius = configuration.layerDistance * depth
44+
val theta = (start + end) / 2f + configuration.rotation
45+
positions[node] = Point(radius * cos(theta), radius * sin(theta))
46+
}
47+
48+
val children = adapter.children(node)
49+
if (children.isEmpty()) return
50+
51+
val totalChildWeight = children.sumOf { weight[it]!! }
52+
val arc = end - start
53+
var offset = start
54+
55+
for (child in children) {
56+
val childArc = arc * (weight[child]!!.toFloat() / totalChildWeight.toFloat())
57+
assignAngles(child, depth + 1, offset, offset + childArc)
58+
offset += childArc
59+
}
60+
}
61+
62+
assignAngles(adapter.root(), 0, 0f, 2f * PI.toFloat())
63+
return DirectAngularResult(positions, maxDepth)
64+
}
65+
66+
private fun computeWeights(node: T, weight: HashMap<T, Int>): Int {
67+
val children = adapter.children(node)
68+
var w = 1
69+
for (child in children) {
70+
w += computeWeights(child, weight)
71+
}
72+
weight[node] = w
73+
return w
74+
}
75+
}
76+
77+
private class DirectAngularResult<T>(
78+
private val positions: Map<T, Point>,
79+
private val maxDepth: Int,
80+
) : TreeLayoutResult<T> {
81+
override fun getPosition(node: T): Point =
82+
positions[node] ?: throw IllegalArgumentException("Node not part of the layout")
83+
84+
override fun getPositions(): Map<T, Point> = positions
85+
86+
override fun getMaxDepth(): Int = maxDepth
87+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.github.linde9821.treelayout.radial.walker
2+
3+
import kotlin.math.PI
4+
5+
/**
6+
* Configuration for the radial Walker layout algorithm.
7+
*
8+
* @property layerDistance Distance between concentric depth rings.
9+
* @property margin Angular margin (in radians) subtracted from the full circle to avoid overlap.
10+
* @property rotation Angular offset (in radians) applied to all nodes.
11+
*/
12+
public data class RadialWalkerLayoutConfiguration(
13+
public val layerDistance: Float = 1.0f,
14+
public val margin: Float = 0.0f,
15+
public val rotation: Float = 0.0f,
16+
) {
17+
internal val usableAngle: Float = 2 * PI.toFloat() - margin
18+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package io.github.linde9821.treelayout.radial.walker
2+
3+
import io.github.linde9821.treelayout.NodeExtentProvider
4+
import io.github.linde9821.treelayout.Point
5+
import io.github.linde9821.treelayout.TreeAdapter
6+
import io.github.linde9821.treelayout.TreeLayoutResult
7+
import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration
8+
import io.github.linde9821.treelayout.walker.WalkerTreeLayout
9+
import kotlin.math.cos
10+
import kotlin.math.max
11+
import kotlin.math.min
12+
import kotlin.math.sin
13+
14+
/**
15+
* A radial variant of the Walker tree layout algorithm.
16+
*
17+
* Nodes are arranged in concentric circles around the root. Each depth level
18+
* forms a ring, and sibling nodes are spaced proportionally along the arc.
19+
* Internally delegates to [WalkerTreeLayout] for the initial linear positioning,
20+
* then transforms the across-axis into angular coordinates.
21+
*
22+
* @param T The node type of the external tree.
23+
* @param adapter Adapter providing tree traversal over the external structure.
24+
* @param configuration Radial layout spacing parameters.
25+
* @param nodeExtentProvider Provides node dimensions. Defaults to dimensionless points (0×0).
26+
*/
27+
public class RadialWalkerTreeLayout<T>(
28+
private val adapter: TreeAdapter<T>,
29+
private val configuration: RadialWalkerLayoutConfiguration = RadialWalkerLayoutConfiguration(),
30+
private val nodeExtentProvider: NodeExtentProvider<T> = UniformNodeExtentProvider(),
31+
) {
32+
/**
33+
* Executes the layout algorithm and returns the result with polar-to-Cartesian positions.
34+
*/
35+
public fun layout(): TreeLayoutResult<T> {
36+
val walkerConfig = WalkerLayoutConfiguration(
37+
horizontalDistance = 1.0f,
38+
verticalDistance = configuration.layerDistance,
39+
)
40+
val linearResult = WalkerTreeLayout(adapter, walkerConfig, nodeExtentProvider).layout()
41+
val linearPositions = linearResult.getPositions()
42+
43+
val depthOf = HashMap<T, Int>(linearPositions.size)
44+
computeDepths(adapter.root(), 0, depthOf)
45+
46+
var xMin = Float.MAX_VALUE
47+
var xMax = Float.MIN_VALUE
48+
for ((_, pos) in linearPositions) {
49+
xMin = min(xMin, pos.x)
50+
xMax = max(xMax, pos.x)
51+
}
52+
53+
val radialPositions = HashMap<T, Point>(linearPositions.size)
54+
val xRange = xMax - xMin
55+
56+
for ((node, pos) in linearPositions) {
57+
val depth = depthOf[node]!!
58+
val radius = depth * configuration.layerDistance
59+
60+
val theta = if (xRange == 0f) {
61+
configuration.rotation
62+
} else {
63+
val normalizedX = (pos.x - xMin) / xRange
64+
normalizedX * configuration.usableAngle + configuration.rotation
65+
}
66+
67+
radialPositions[node] = Point(
68+
x = radius * cos(theta),
69+
y = radius * sin(theta),
70+
)
71+
}
72+
73+
return RadialLayoutResult(radialPositions, linearResult.getMaxDepth())
74+
}
75+
76+
private fun computeDepths(node: T, depth: Int, depthOf: HashMap<T, Int>) {
77+
depthOf[node] = depth
78+
for (child in adapter.children(node)) {
79+
computeDepths(child, depth + 1, depthOf)
80+
}
81+
}
82+
}
83+
84+
private class UniformNodeExtentProvider<T> : NodeExtentProvider<T> {
85+
override fun width(node: T): Float = 0.0f
86+
override fun height(node: T): Float = 0.0f
87+
}
88+
89+
private class RadialLayoutResult<T>(
90+
private val positions: Map<T, Point>,
91+
private val maxDepth: Int,
92+
) : TreeLayoutResult<T> {
93+
override fun getPosition(node: T): Point =
94+
positions[node] ?: throw IllegalArgumentException("Node not part of the layout")
95+
96+
override fun getPositions(): Map<T, Point> = positions
97+
98+
override fun getMaxDepth(): Int = maxDepth
99+
}

0 commit comments

Comments
 (0)