Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,40 @@ jobs:
- uses: gradle/actions/setup-gradle@v4

- name: Build and test
run: ./gradlew :library:check
run: ./gradlew :library:check

- name: Generate coverage report
run: ./gradlew :library:jvmTest :library:jacocoCoverageReport

- name: Parse coverage percentage
id: coverage
run: |
REPORT="library/build/reports/jacoco/jacocoCoverageReport/jacocoCoverageReport.xml"
if [ -f "$REPORT" ]; then
# JaCoCo: <counter type="LINE" missed="X" covered="Y"/> (last occurrence = project total)
LINE=$(grep 'type="LINE"' "$REPORT" | tail -1)
LINE_MISSED=$(echo "$LINE" | sed 's/.*missed="\([0-9]*\)".*/\1/')
LINE_COVERED=$(echo "$LINE" | sed 's/.*covered="\([0-9]*\)".*/\1/')
TOTAL=$((LINE_COVERED + LINE_MISSED))
if [ "$TOTAL" -gt 0 ]; then
PERCENT=$((LINE_COVERED * 100 / TOTAL))
else
PERCENT=0
fi
else
PERCENT=0
fi
echo "percentage=$PERCENT" >> "$GITHUB_OUTPUT"

- name: Update coverage badge
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: schneegans/dynamic-badges-action@v1.7.0
with:
auth: ${{ secrets.GIST_SECRET }}
gistID: ${{ vars.COVERAGE_GIST_ID }}
filename: coverage.json
label: coverage
message: ${{ steps.coverage.outputs.percentage }}%
valColorRange: ${{ steps.coverage.outputs.percentage }}
minColorRange: 50
maxColorRange: 90
118 changes: 101 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# TreeLayoutKMP

[![CI](https://github.com/linde9821/TreeLayoutKMP/actions/workflows/ci.yml/badge.svg)](https://github.com/linde9821/TreeLayoutKMP/actions/workflows/ci.yml)

[![Coverage](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/linde9821/COVERAGE_GIST_ID/raw/coverage.json)](https://github.com/linde9821/TreeLayoutKMP/actions/workflows/ci.yml)
[![Maven Central](https://img.shields.io/maven-central/v/io.github.linde9821/treelayout-kmp)](https://central.sonatype.com/artifact/io.github.linde9821/treelayout-kmp)

**[Live Demo](https://linde9821.github.io/TreeLayoutKMP/)** · **[API Docs](https://linde9821.github.io/TreeLayoutKMP/api/)**
**[Live Demo](https://linde9821.github.io/TreeLayoutKMP/)** · *
*[API Docs](https://linde9821.github.io/TreeLayoutKMP/api/)**

> ⚠️ **This library is under active development and has not reached a stable release yet.**
> The API may change between versions. Feedback and contributions are welcome.
Expand Down Expand Up @@ -32,7 +33,7 @@ your nodes; the library computes optimal (x, y) coordinates for every node in th
```kotlin
// build.gradle.kts
dependencies {
implementation("io.github.linde9821:treelayout-kmp:0.4.0")
implementation("io.github.linde9821:treelayout-kmp:0.5.0")
}
```

Expand Down Expand Up @@ -117,6 +118,57 @@ println("Tree depth: ${result.getMaxDepth()}")
Coordinates use a top-down orientation: the root is at `y = 0`, and depth increases downward by `verticalDistance` per
level.

### Transformations

`TreeLayoutResult<T>` provides chainable methods for adapting coordinates to your rendering target:

```kotlin
// Scale and center within a viewport
val fitted = result.centered(canvasWidth, canvasHeight)

// Flip Y for a canvas where Y grows downward
val flipped = result.scaledTo(800f, 600f).mapped { Point(it.x, 600f - it.y) }

// Arbitrary transform — rotation, padding, coordinate remapping
val padded = result.normalized().mapped { Point(it.x + 20f, it.y + 20f) }
```

All transformation methods return a new `TreeLayoutResult<T>` — the original is never mutated.

### Animated Transitions

`LayoutTransition<T>` enables smooth animations between two layout states (e.g., when the tree structure changes):

```kotlin
import io.github.linde9821.treelayout.result.LayoutTransition

val transition = LayoutTransition(oldResult, newResult)

// Query which nodes are entering/exiting
transition.enteringNodes // nodes added in newResult
transition.exitingNodes // nodes removed from oldResult

// In your animation loop (framework-agnostic):
val frame = transition.interpolate(progress) // 0.0 → oldResult, 1.0 → newResult
```

In Jetpack Compose, drive `progress` with `Animatable` or `animateFloatAsState` and call `interpolate` each frame.

### Serialization

Zero-dependency JSON serialization for caching or transmitting layouts:

```kotlin
import io.github.linde9821.treelayout.result.toJson
import io.github.linde9821.treelayout.result.fromJson

// Serialize
val json = result.toJson { node -> node.id }

// Deserialize
val restored = TreeLayoutResult.fromJson(json) { id -> findNodeById(id) }
```

### 4. Variable Node Sizes

By default, nodes are treated as dimensionless points. For real-world trees where nodes have varying widths and heights
Expand Down Expand Up @@ -207,7 +259,8 @@ val result = RadialWalkerTreeLayout(

**Package:** `io.github.linde9821.treelayout.radial.angular`

This algorithm recursively partitions angular space among children proportional to their **subtree weight** (total number
This algorithm recursively partitions angular space among children proportional to their **subtree weight** (total
number
of descendants including the child itself). It runs in **O(n)** — one pass to compute weights, one pass to assign
positions. This produces evenly distributed layouts where larger subtrees receive proportionally more angular space.

Expand Down Expand Up @@ -281,11 +334,11 @@ Constructor accepts an optional `nodeExtentProvider` parameter. When omitted, no

### `RadialWalkerLayoutConfiguration`

| Property | Type | Default | Description |
|-----------------|---------|---------|----------------------------------------------------------------|
| `layerDistance` | `Float` | `1.0f` | Radial distance between concentric depth rings. |
| `margin` | `Float` | `0.0f` | Angular margin (in radians) subtracted from the full circle. |
| `rotation` | `Float` | `0.0f` | Angular offset (in radians) applied to all node positions. |
| Property | Type | Default | Description |
|-----------------|---------|---------|--------------------------------------------------------------|
| `layerDistance` | `Float` | `1.0f` | Radial distance between concentric depth rings. |
| `margin` | `Float` | `0.0f` | Angular margin (in radians) subtracted from the full circle. |
| `rotation` | `Float` | `0.0f` | Angular offset (in radians) applied to all node positions. |

### `RadialWalkerTreeLayout<T>`

Expand All @@ -299,9 +352,9 @@ Constructor parameters: `adapter: TreeAdapter<T>`, `configuration: RadialWalkerL
### `DirectAngularPlacementConfiguration`

| Property | Type | Default | Description |
|-----------------|---------|---------|-------------------------------------------------------------|
| `layerDistance` | `Float` | `1.0f` | Radial distance between concentric depth rings. |
| `rotation` | `Float` | `0.0f` | Angular offset (in radians) applied to all node positions. |
|-----------------|---------|---------|------------------------------------------------------------|
| `layerDistance` | `Float` | `1.0f` | Radial distance between concentric depth rings. |
| `rotation` | `Float` | `0.0f` | Angular offset (in radians) applied to all node positions. |

### `DirectAngularPlacementLayout<T>`

Expand All @@ -313,11 +366,42 @@ Constructor parameters: `adapter: TreeAdapter<T>`, `configuration: DirectAngular

### `TreeLayoutResult<T>`

| Method | Description |
|---------------------------------|-------------------------------------------------|
| `getPosition(node: T): Point` | Returns the `(x, y)` coordinate for a node. |
| `getPositions(): Map<T, Point>` | Returns all node-to-coordinate mappings. |
| `getMaxDepth(): Int` | Returns the maximum depth of the laid-out tree. |
A concrete class representing the output of any layout algorithm. Can also be constructed directly for testing or custom
layout engines: `TreeLayoutResult(positions: Map<T, Point>, maxDepth: Int)`.

| Method | Description |
|-------------------------------------------------------|-----------------------------------------------------------------------------|
| `getPosition(node: T): Point` | Returns the `(x, y)` coordinate for a node. |
| `getPositions(): Map<T, Point>` | Returns all node-to-coordinate mappings. |
| `getMaxDepth(): Int` | Returns the maximum depth of the laid-out tree. |
| `getBounds(): Bounds` | Returns the axis-aligned bounding box of all positions. |
| `normalized(): TreeLayoutResult<T>` | Shifts positions so the minimum corner is at the origin. |
| `translated(dx: Float, dy: Float): TreeLayoutResult<T>` | Shifts all positions by the given offset. |
| `scaledTo(width: Float, height: Float): TreeLayoutResult<T>` | Scales to fit within the given dimensions (preserves aspect ratio). |
| `centered(width: Float, height: Float): TreeLayoutResult<T>` | Scales to fit and centers within a viewport. |
| `mapped(transform: (Point) -> Point): TreeLayoutResult<T>` | Applies an arbitrary transform to all positions. |

### `LayoutTransition<T>`

Enables animated transitions between two layout states.

| Member | Description |
|-----------------------------------------------|--------------------------------------------------------------------|
| `LayoutTransition(from, to)` | Constructor taking the start and end `TreeLayoutResult`. |
| `allNodes: Set<T>` | All nodes in either state. |
| `persistentNodes: Set<T>` | Nodes present in both states. |
| `enteringNodes: Set<T>` | Nodes only in the end state. |
| `exitingNodes: Set<T>` | Nodes only in the start state. |
| `interpolate(progress: Float): TreeLayoutResult<T>` | Returns positions interpolated between start and end (0.0–1.0). |

### Serialization Extensions

Extension functions in `io.github.linde9821.treelayout.result`:

| Function | Description |
|-----------------------------------------------------------------------------------|------------------------------------------------|
| `TreeLayoutResult<T>.toJson(nodeToKey: (T) -> String): String` | Serializes the layout to a JSON string. |
| `TreeLayoutResult.Companion.fromJson(json: String, keyToNode: (String) -> T): TreeLayoutResult<T>` | Deserializes a layout from JSON. |

### `Point`

Expand Down
24 changes: 23 additions & 1 deletion library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ plugins {
alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.vanniktech.mavenPublish)
alias(libs.plugins.dokka)
jacoco
}

group = "io.github.linde9821"
version = "0.4.0"
version = "0.5.0"

dokka {
moduleName.set("TreeLayoutKMP")
Expand Down Expand Up @@ -124,3 +125,24 @@ mavenPublishing {
}
}
}

tasks.named("jvmTest") {
finalizedBy("jacocoCoverageReport")
}

tasks.register<JacocoReport>("jacocoCoverageReport") {
dependsOn("jvmTest")
reports {
xml.required.set(true)
html.required.set(true)
}
classDirectories.setFrom(
fileTree("build/classes/kotlin/jvm/main")
)
sourceDirectories.setFrom(
files("src/commonMain/kotlin")
)
executionData.setFrom(
fileTree("build") { include("jacoco/jvmTest.exec") }
)
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
# Module TreeLayoutKMP

A Kotlin Multiplatform library for computing tidy tree layouts using the Walker/Buchheim algorithm in O(n) time.
Includes transformation utilities for adapting coordinates to any rendering target, animation support for smooth
transitions between layout states, and zero-dependency JSON serialization for caching or transmitting results.

## Getting Started

Create a [TreeAdapter][io.github.linde9821.treelayout.TreeAdapter] for your tree structure, then pass it to [WalkerTreeLayout][io.github.linde9821.treelayout.walker.WalkerTreeLayout]:
Create a [TreeAdapter][io.github.linde9821.treelayout.TreeAdapter] for your tree structure, then pass it
to [WalkerTreeLayout][io.github.linde9821.treelayout.walker.WalkerTreeLayout]:

```kotlin
val layout = WalkerTreeLayout(adapter, WalkerLayoutConfiguration(horizontalDistance = 2f))
Expand All @@ -14,11 +17,11 @@ val position = result.getPosition(myNode)

## Layout Algorithms

| Algorithm | Class | Use Case |
|-----------|-------|----------|
| Walker (Buchheim) | [WalkerTreeLayout][io.github.linde9821.treelayout.walker.WalkerTreeLayout] | Standard tidy tree layout |
| Radial Walker | [RadialWalkerTreeLayout][io.github.linde9821.treelayout.radial.walker.RadialWalkerTreeLayout] | Concentric ring layout |
| Direct Angular | [DirectAngularPlacementLayout][io.github.linde9821.treelayout.radial.angular.DirectAngularPlacementLayout] | Proportional angular partitioning |
| Algorithm | Class | Use Case |
|-------------------|------------------------------------------------------------------------------------------------------------|-----------------------------------|
| Walker (Buchheim) | [WalkerTreeLayout][io.github.linde9821.treelayout.walker.WalkerTreeLayout] | Standard tidy tree layout |
| Radial Walker | [RadialWalkerTreeLayout][io.github.linde9821.treelayout.radial.walker.RadialWalkerTreeLayout] | Concentric ring layout |
| Direct Angular | [DirectAngularPlacementLayout][io.github.linde9821.treelayout.radial.angular.DirectAngularPlacementLayout] | Proportional angular partitioning |

# Package io.github.linde9821.treelayout

Expand All @@ -38,4 +41,8 @@ Direct angular partitioning layout for radial trees.

# Package io.github.linde9821.treelayout.result

Layout result types and bounding box utilities.
Layout result types, transformation utilities, and bounding box helpers.
[TreeLayoutResult][io.github.linde9821.treelayout.result.TreeLayoutResult] is a concrete class providing chainable
coordinate transforms (`mapped`, `centered`, `scaledTo`, `normalized`, `translated`).
[LayoutTransition][io.github.linde9821.treelayout.result.LayoutTransition] enables animated interpolation between
two layout states. Extension functions `toJson` and `fromJson` provide zero-dependency JSON serialization.
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public class DirectAngularPlacementLayout<T>(
}

assignAngles(adapter.root(), 0, 0f, 2f * PI.toFloat())
return DirectAngularResult(positions, maxDepth)
return TreeLayoutResult(positions, maxDepth)
}

private fun computeWeights(node: T, weight: HashMap<T, Int>): Int {
Expand All @@ -73,15 +73,3 @@ public class DirectAngularPlacementLayout<T>(
return w
}
}

private class DirectAngularResult<T>(
private val positions: Map<T, Point>,
private val maxDepth: Int,
) : TreeLayoutResult<T>() {
override fun getPosition(node: T): Point =
positions[node] ?: throw IllegalArgumentException("Node not part of the layout")

override fun getPositions(): Map<T, Point> = positions

override fun getMaxDepth(): Int = maxDepth
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public class RadialWalkerTreeLayout<T>(
)
}

return RadialLayoutResult(radialPositions, linearResult.getMaxDepth())
return TreeLayoutResult(radialPositions, linearResult.getMaxDepth())
}

private fun computeDepths(node: T, depth: Int, depthOf: HashMap<T, Int>) {
Expand All @@ -85,15 +85,3 @@ private class UniformNodeExtentProvider<T> : NodeExtentProvider<T> {
override fun width(node: T): Float = 0.0f
override fun height(node: T): Float = 0.0f
}

private class RadialLayoutResult<T>(
private val positions: Map<T, Point>,
private val maxDepth: Int,
) : TreeLayoutResult<T>() {
override fun getPosition(node: T): Point =
positions[node] ?: throw IllegalArgumentException("Node not part of the layout")

override fun getPositions(): Map<T, Point> = positions

override fun getMaxDepth(): Int = maxDepth
}
Loading
Loading