diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4104e25..de5a041 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,4 +24,40 @@ jobs:
- uses: gradle/actions/setup-gradle@v4
- name: Build and test
- run: ./gradlew :library:check
\ No newline at end of file
+ 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: (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
diff --git a/README.md b/README.md
index 082367d..3429e18 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,11 @@
# TreeLayoutKMP
[](https://github.com/linde9821/TreeLayoutKMP/actions/workflows/ci.yml)
-
+[](https://github.com/linde9821/TreeLayoutKMP/actions/workflows/ci.yml)
[](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.
@@ -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")
}
```
@@ -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` 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` — the original is never mutated.
+
+### Animated Transitions
+
+`LayoutTransition` 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
@@ -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.
@@ -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`
@@ -299,9 +352,9 @@ Constructor parameters: `adapter: TreeAdapter`, `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`
@@ -313,11 +366,42 @@ Constructor parameters: `adapter: TreeAdapter`, `configuration: DirectAngular
### `TreeLayoutResult`
-| Method | Description |
-|---------------------------------|-------------------------------------------------|
-| `getPosition(node: T): Point` | Returns the `(x, y)` coordinate for a node. |
-| `getPositions(): Map` | 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, maxDepth: Int)`.
+
+| Method | Description |
+|-------------------------------------------------------|-----------------------------------------------------------------------------|
+| `getPosition(node: T): Point` | Returns the `(x, y)` coordinate for a node. |
+| `getPositions(): Map` | 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` | Shifts positions so the minimum corner is at the origin. |
+| `translated(dx: Float, dy: Float): TreeLayoutResult` | Shifts all positions by the given offset. |
+| `scaledTo(width: Float, height: Float): TreeLayoutResult` | Scales to fit within the given dimensions (preserves aspect ratio). |
+| `centered(width: Float, height: Float): TreeLayoutResult` | Scales to fit and centers within a viewport. |
+| `mapped(transform: (Point) -> Point): TreeLayoutResult` | Applies an arbitrary transform to all positions. |
+
+### `LayoutTransition`
+
+Enables animated transitions between two layout states.
+
+| Member | Description |
+|-----------------------------------------------|--------------------------------------------------------------------|
+| `LayoutTransition(from, to)` | Constructor taking the start and end `TreeLayoutResult`. |
+| `allNodes: Set` | All nodes in either state. |
+| `persistentNodes: Set` | Nodes present in both states. |
+| `enteringNodes: Set` | Nodes only in the end state. |
+| `exitingNodes: Set` | Nodes only in the start state. |
+| `interpolate(progress: Float): TreeLayoutResult` | Returns positions interpolated between start and end (0.0–1.0). |
+
+### Serialization Extensions
+
+Extension functions in `io.github.linde9821.treelayout.result`:
+
+| Function | Description |
+|-----------------------------------------------------------------------------------|------------------------------------------------|
+| `TreeLayoutResult.toJson(nodeToKey: (T) -> String): String` | Serializes the layout to a JSON string. |
+| `TreeLayoutResult.Companion.fromJson(json: String, keyToNode: (String) -> T): TreeLayoutResult` | Deserializes a layout from JSON. |
### `Point`
diff --git a/library/build.gradle.kts b/library/build.gradle.kts
index d0f858f..43c48eb 100644
--- a/library/build.gradle.kts
+++ b/library/build.gradle.kts
@@ -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")
@@ -124,3 +125,24 @@ mavenPublishing {
}
}
}
+
+tasks.named("jvmTest") {
+ finalizedBy("jacocoCoverageReport")
+}
+
+tasks.register("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") }
+ )
+}
diff --git a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/Module.md b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/Module.md
index a971056..8dc2532 100644
--- a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/Module.md
+++ b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/Module.md
@@ -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))
@@ -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
@@ -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.
diff --git a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/radial/angular/DirectAngularPlacementLayout.kt b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/radial/angular/DirectAngularPlacementLayout.kt
index 7d9164f..35838d4 100644
--- a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/radial/angular/DirectAngularPlacementLayout.kt
+++ b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/radial/angular/DirectAngularPlacementLayout.kt
@@ -60,7 +60,7 @@ public class DirectAngularPlacementLayout(
}
assignAngles(adapter.root(), 0, 0f, 2f * PI.toFloat())
- return DirectAngularResult(positions, maxDepth)
+ return TreeLayoutResult(positions, maxDepth)
}
private fun computeWeights(node: T, weight: HashMap): Int {
@@ -73,15 +73,3 @@ public class DirectAngularPlacementLayout(
return w
}
}
-
-private class DirectAngularResult(
- private val positions: Map,
- private val maxDepth: Int,
-) : TreeLayoutResult() {
- override fun getPosition(node: T): Point =
- positions[node] ?: throw IllegalArgumentException("Node not part of the layout")
-
- override fun getPositions(): Map = positions
-
- override fun getMaxDepth(): Int = maxDepth
-}
diff --git a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/radial/walker/RadialWalkerTreeLayout.kt b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/radial/walker/RadialWalkerTreeLayout.kt
index 581069b..d012bf4 100644
--- a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/radial/walker/RadialWalkerTreeLayout.kt
+++ b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/radial/walker/RadialWalkerTreeLayout.kt
@@ -70,7 +70,7 @@ public class RadialWalkerTreeLayout(
)
}
- return RadialLayoutResult(radialPositions, linearResult.getMaxDepth())
+ return TreeLayoutResult(radialPositions, linearResult.getMaxDepth())
}
private fun computeDepths(node: T, depth: Int, depthOf: HashMap) {
@@ -85,15 +85,3 @@ private class UniformNodeExtentProvider : NodeExtentProvider {
override fun width(node: T): Float = 0.0f
override fun height(node: T): Float = 0.0f
}
-
-private class RadialLayoutResult(
- private val positions: Map,
- private val maxDepth: Int,
-) : TreeLayoutResult() {
- override fun getPosition(node: T): Point =
- positions[node] ?: throw IllegalArgumentException("Node not part of the layout")
-
- override fun getPositions(): Map = positions
-
- override fun getMaxDepth(): Int = maxDepth
-}
diff --git a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/LayoutTransition.kt b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/LayoutTransition.kt
new file mode 100644
index 0000000..a04da21
--- /dev/null
+++ b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/LayoutTransition.kt
@@ -0,0 +1,61 @@
+package io.github.linde9821.treelayout.result
+
+import io.github.linde9821.treelayout.Point
+
+/**
+ * Represents a transition between two layout states, enabling animation
+ * by interpolating node positions at a given progress fraction.
+ *
+ * Nodes present only in [from] are treated as exiting (animate to their last known position).
+ * Nodes present only in [to] are treated as entering (animate from their target position).
+ *
+ * @param T The node type of the external tree.
+ * @param from The starting layout state.
+ * @param to The ending layout state.
+ */
+public class LayoutTransition(
+ private val from: TreeLayoutResult,
+ private val to: TreeLayoutResult,
+) {
+ /** All nodes involved in either the start or end state. */
+ public val allNodes: Set = from.getPositions().keys + to.getPositions().keys
+
+ /** Nodes present in both states. */
+ public val persistentNodes: Set = from.getPositions().keys.intersect(to.getPositions().keys)
+
+ /** Nodes only in [from] (removed in the new layout). */
+ public val exitingNodes: Set = from.getPositions().keys - to.getPositions().keys
+
+ /** Nodes only in [to] (added in the new layout). */
+ public val enteringNodes: Set = to.getPositions().keys - from.getPositions().keys
+
+ /**
+ * Returns an interpolated layout at the given [progress] (0.0 = [from], 1.0 = [to]).
+ *
+ * - Persistent nodes are linearly interpolated between their start and end positions.
+ * - Exiting nodes remain at their [from] position.
+ * - Entering nodes remain at their [to] position.
+ */
+ public fun interpolate(progress: Float): TreeLayoutResult {
+ val clamped = progress.coerceIn(0f, 1f)
+ val positions = HashMap(allNodes.size)
+
+ for (node in persistentNodes) {
+ val a = from.getPosition(node)
+ val b = to.getPosition(node)
+ positions[node] = Point(
+ x = a.x + (b.x - a.x) * clamped,
+ y = a.y + (b.y - a.y) * clamped,
+ )
+ }
+ for (node in exitingNodes) {
+ positions[node] = from.getPosition(node)
+ }
+ for (node in enteringNodes) {
+ positions[node] = to.getPosition(node)
+ }
+
+ val maxDepth = maxOf(from.getMaxDepth(), to.getMaxDepth())
+ return TreeLayoutResult(positions, maxDepth)
+ }
+}
diff --git a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/MapTreeLayoutResult.kt b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/MapTreeLayoutResult.kt
deleted file mode 100644
index 663d519..0000000
--- a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/MapTreeLayoutResult.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package io.github.linde9821.treelayout.result
-
-import io.github.linde9821.treelayout.Point
-
-/**
- * Simple map-backed implementation used by transformation methods.
- */
-internal class MapTreeLayoutResult(
- private val positions: Map,
- private val maxDepth: Int,
-) : TreeLayoutResult() {
- override fun getPosition(node: T): Point =
- positions[node] ?: throw IllegalArgumentException("Node not part of the layout")
-
- override fun getPositions(): Map = positions
-
- override fun getMaxDepth(): Int = maxDepth
-}
\ No newline at end of file
diff --git a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/Serialization.kt b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/Serialization.kt
new file mode 100644
index 0000000..33e1991
--- /dev/null
+++ b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/Serialization.kt
@@ -0,0 +1,82 @@
+package io.github.linde9821.treelayout.result
+
+import io.github.linde9821.treelayout.Point
+
+/**
+ * Serializes a [TreeLayoutResult] to a JSON string.
+ *
+ * @param nodeToKey Converts a node to its string key for serialization.
+ * @return A JSON string representing the layout result.
+ */
+public fun TreeLayoutResult.toJson(nodeToKey: (T) -> String): String {
+ val entries = getPositions().entries.joinToString(",") { (node, point) ->
+ val key = nodeToKey(node).escapeJson()
+ """{"k":"$key","px":${point.x},"py":${point.y}}"""
+ }
+ return """{"maxDepth":${getMaxDepth()},"positions":[$entries]}"""
+}
+
+/**
+ * Deserializes a [TreeLayoutResult] from a JSON string previously produced by [toJson].
+ *
+ * @param json The JSON string to parse.
+ * @param keyToNode Converts a string key back to the node instance.
+ * @return The deserialized [TreeLayoutResult].
+ */
+public fun TreeLayoutResult.Companion.fromJson(
+ json: String,
+ keyToNode: (String) -> T,
+): TreeLayoutResult {
+ val maxDepth = json.extractInt("maxDepth")
+ val positions = HashMap()
+ val positionsArray = json.substringAfter("\"positions\":[").substringBeforeLast("]")
+
+ if (positionsArray.isNotBlank()) {
+ var remaining = positionsArray
+ while (remaining.isNotBlank()) {
+ val obj = remaining.substringAfter("{").substringBefore("}")
+ remaining = remaining.substringAfter("}")
+ if (remaining.startsWith(",")) remaining = remaining.substring(1)
+
+ val key = obj.extractString("k").unescapeJson()
+ val x = obj.extractFloat("px")
+ val y = obj.extractFloat("py")
+ positions[keyToNode(key)] = Point(x, y)
+ }
+ }
+
+ return TreeLayoutResult(positions, maxDepth)
+}
+
+private fun String.extractInt(key: String): Int {
+ val after = substringAfter("\"$key\":")
+ return after.takeWhile { it.isDigit() || it == '-' }.toInt()
+}
+
+private fun String.extractFloat(key: String): Float {
+ val after = substringAfter("\"$key\":")
+ return after.takeWhile { it.isDigit() || it == '-' || it == '.' || it == 'E' || it == 'e' || it == '+' }.toFloat()
+}
+
+private fun String.extractString(key: String): String {
+ val after = substringAfter("\"$key\":\"")
+ val sb = StringBuilder()
+ var i = 0
+ while (i < after.length) {
+ if (after[i] == '\\' && i + 1 < after.length) {
+ sb.append(after[i])
+ sb.append(after[i + 1])
+ i += 2
+ } else if (after[i] == '"') {
+ break
+ } else {
+ sb.append(after[i])
+ i++
+ }
+ }
+ return sb.toString()
+}
+
+private fun String.escapeJson(): String = replace("\\", "\\\\").replace("\"", "\\\"")
+
+private fun String.unescapeJson(): String = replace("\\\"", "\"").replace("\\\\", "\\")
diff --git a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/TreeLayoutResult.kt b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/TreeLayoutResult.kt
index ecaecf1..126d581 100644
--- a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/TreeLayoutResult.kt
+++ b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/TreeLayoutResult.kt
@@ -9,26 +9,31 @@ import kotlin.math.min
*
* @param T The node type of the external tree.
*/
-public abstract class TreeLayoutResult {
+public class TreeLayoutResult(
+ private val positions: Map,
+ private val maxDepth: Int,
+) {
+ public companion object {}
/** Returns the position assigned to [node]. */
- public abstract fun getPosition(node: T): Point
+ public fun getPosition(node: T): Point =
+ positions[node] ?: throw IllegalArgumentException("Node not part of the layout")
/** Returns all node-to-position mappings. */
- public abstract fun getPositions(): Map
+ public fun getPositions(): Map = positions
/** Returns the maximum depth of the tree. */
- public abstract fun getMaxDepth(): Int
+ public fun getMaxDepth(): Int = maxDepth
/** Returns the axis-aligned bounding box of all node positions. */
public fun getBounds(): Bounds {
- val positions = getPositions().values
- if (positions.isEmpty()) return Bounds(0f, 0f, 0f, 0f)
+ val values = positions.values
+ if (values.isEmpty()) return Bounds(0f, 0f, 0f, 0f)
var minX = Float.MAX_VALUE
var minY = Float.MAX_VALUE
- var maxX = -Float.MAX_VALUE
- var maxY = -Float.MAX_VALUE
- for (p in positions) {
+ var maxX = Float.MIN_VALUE
+ var maxY = Float.MIN_VALUE
+ for (p in values) {
if (p.x < minX) minX = p.x
if (p.y < minY) minY = p.y
if (p.x > maxX) maxX = p.x
@@ -44,10 +49,12 @@ public abstract class TreeLayoutResult {
}
/** Returns a new result with all positions shifted by [dx] and [dy]. */
- public fun translated(dx: Float, dy: Float): TreeLayoutResult {
- val mapped = getPositions().mapValues { (_, p) -> Point(p.x + dx, p.y + dy) }
- return MapTreeLayoutResult(mapped, getMaxDepth())
- }
+ public fun translated(dx: Float, dy: Float): TreeLayoutResult =
+ TreeLayoutResult(positions.mapValues { (_, p) -> Point(p.x + dx, p.y + dy) }, maxDepth)
+
+ /** Returns a new result with all positions transformed by [transform]. */
+ public fun mapped(transform: (Point) -> Point): TreeLayoutResult =
+ TreeLayoutResult(positions.mapValues { (_, p) -> transform(p) }, maxDepth)
/**
* Returns a new result uniformly scaled to fit within [width]×[height],
@@ -63,9 +70,24 @@ public abstract class TreeLayoutResult {
bh == 0f -> width / bw
else -> min(width / bw, height / bh)
}
- val mapped = getPositions().mapValues { (_, p) ->
- Point((p.x - bounds.minX) * scale, (p.y - bounds.minY) * scale)
- }
- return MapTreeLayoutResult(mapped, getMaxDepth())
+ return TreeLayoutResult(
+ positions.mapValues { (_, p) ->
+ Point((p.x - bounds.minX) * scale, (p.y - bounds.minY) * scale)
+ },
+ maxDepth,
+ )
+ }
+
+ /**
+ * Returns a new result centered within a viewport of [width]×[height].
+ * The layout is first scaled to fit (preserving aspect ratio), then offset
+ * so that the bounding box is centered in the viewport.
+ */
+ public fun centered(width: Float, height: Float): TreeLayoutResult {
+ val scaled = scaledTo(width, height)
+ val bounds = scaled.getBounds()
+ val dx = (width - bounds.width) / 2f - bounds.minX
+ val dy = (height - bounds.height) / 2f - bounds.minY
+ return scaled.translated(dx, dy)
}
-}
\ No newline at end of file
+}
diff --git a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/walker/WalkerTreeLayout.kt b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/walker/WalkerTreeLayout.kt
index 571184e..0c257ce 100644
--- a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/walker/WalkerTreeLayout.kt
+++ b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/walker/WalkerTreeLayout.kt
@@ -75,7 +75,7 @@ private class LayoutContext(
}
fun buildResult(): TreeLayoutResult {
- return LayoutResultImpl(positions.toMap(), maxDepth)
+ return TreeLayoutResult(positions.toMap(), maxDepth)
}
private fun initNodes(node: T, depth: Int) {
@@ -249,15 +249,3 @@ private class LayoutContext(
private fun numberOf(v: T): Int = (indexAmongSiblings[v] ?: 0) + 1
}
-
-private class LayoutResultImpl(
- private val positions: Map,
- private val maxDepth: Int,
-) : TreeLayoutResult() {
- override fun getPosition(node: T): Point =
- positions[node] ?: throw IllegalArgumentException("Node not part of the layout")
-
- override fun getPositions(): Map = positions
-
- override fun getMaxDepth(): Int = maxDepth
-}
diff --git a/library/src/commonTest/kotlin/io/github/linde9821/treelayout/result/LayoutTransitionTest.kt b/library/src/commonTest/kotlin/io/github/linde9821/treelayout/result/LayoutTransitionTest.kt
new file mode 100644
index 0000000..5e7851d
--- /dev/null
+++ b/library/src/commonTest/kotlin/io/github/linde9821/treelayout/result/LayoutTransitionTest.kt
@@ -0,0 +1,81 @@
+package io.github.linde9821.treelayout.result
+
+import io.github.linde9821.treelayout.Point
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class LayoutTransitionTest {
+
+ @Test
+ fun interpolateAtZeroReturnsFromPositions() {
+ val from = TreeLayoutResult(mapOf("a" to Point(0f, 0f), "b" to Point(10f, 10f)), 2)
+ val to = TreeLayoutResult(mapOf("a" to Point(20f, 20f), "b" to Point(30f, 30f)), 2)
+ val result = LayoutTransition(from, to).interpolate(0f)
+ assertEquals(Point(0f, 0f), result.getPosition("a"))
+ assertEquals(Point(10f, 10f), result.getPosition("b"))
+ }
+
+ @Test
+ fun interpolateAtOneReturnsToPositions() {
+ val from = TreeLayoutResult(mapOf("a" to Point(0f, 0f)), 1)
+ val to = TreeLayoutResult(mapOf("a" to Point(10f, 20f)), 1)
+ val result = LayoutTransition(from, to).interpolate(1f)
+ assertEquals(Point(10f, 20f), result.getPosition("a"))
+ }
+
+ @Test
+ fun interpolateAtHalfReturnsMidpoint() {
+ val from = TreeLayoutResult(mapOf("a" to Point(0f, 0f)), 1)
+ val to = TreeLayoutResult(mapOf("a" to Point(10f, 20f)), 1)
+ val result = LayoutTransition(from, to).interpolate(0.5f)
+ assertEquals(Point(5f, 10f), result.getPosition("a"))
+ }
+
+ @Test
+ fun enteringNodesStayAtTargetPosition() {
+ val from = TreeLayoutResult(mapOf("a" to Point(0f, 0f)), 1)
+ val to = TreeLayoutResult(mapOf("a" to Point(10f, 10f), "b" to Point(20f, 20f)), 2)
+ val transition = LayoutTransition(from, to)
+ assertEquals(setOf("b"), transition.enteringNodes)
+ val result = transition.interpolate(0.5f)
+ assertEquals(Point(20f, 20f), result.getPosition("b"))
+ }
+
+ @Test
+ fun exitingNodesStayAtFromPosition() {
+ val from = TreeLayoutResult(mapOf("a" to Point(0f, 0f), "b" to Point(5f, 5f)), 2)
+ val to = TreeLayoutResult(mapOf("a" to Point(10f, 10f)), 1)
+ val transition = LayoutTransition(from, to)
+ assertEquals(setOf("b"), transition.exitingNodes)
+ val result = transition.interpolate(0.5f)
+ assertEquals(Point(5f, 5f), result.getPosition("b"))
+ }
+
+ @Test
+ fun allNodesContainsUnionOfBothStates() {
+ val from = TreeLayoutResult(mapOf("a" to Point(0f, 0f), "b" to Point(1f, 1f)), 1)
+ val to = TreeLayoutResult(mapOf("b" to Point(2f, 2f), "c" to Point(3f, 3f)), 1)
+ val transition = LayoutTransition(from, to)
+ assertEquals(setOf("a", "b", "c"), transition.allNodes)
+ assertEquals(setOf("b"), transition.persistentNodes)
+ assertEquals(setOf("a"), transition.exitingNodes)
+ assertEquals(setOf("c"), transition.enteringNodes)
+ }
+
+ @Test
+ fun maxDepthIsMaxOfBothStates() {
+ val from = TreeLayoutResult(mapOf("a" to Point(0f, 0f)), 3)
+ val to = TreeLayoutResult(mapOf("a" to Point(1f, 1f)), 5)
+ val result = LayoutTransition(from, to).interpolate(0.5f)
+ assertEquals(5, result.getMaxDepth())
+ }
+
+ @Test
+ fun progressIsClampedToZeroOne() {
+ val from = TreeLayoutResult(mapOf("a" to Point(0f, 0f)), 1)
+ val to = TreeLayoutResult(mapOf("a" to Point(10f, 10f)), 1)
+ val transition = LayoutTransition(from, to)
+ assertEquals(Point(0f, 0f), transition.interpolate(-1f).getPosition("a"))
+ assertEquals(Point(10f, 10f), transition.interpolate(2f).getPosition("a"))
+ }
+}
diff --git a/library/src/commonTest/kotlin/io/github/linde9821/treelayout/result/SerializationTest.kt b/library/src/commonTest/kotlin/io/github/linde9821/treelayout/result/SerializationTest.kt
new file mode 100644
index 0000000..0c55d4c
--- /dev/null
+++ b/library/src/commonTest/kotlin/io/github/linde9821/treelayout/result/SerializationTest.kt
@@ -0,0 +1,65 @@
+package io.github.linde9821.treelayout.result
+
+import io.github.linde9821.treelayout.Point
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class SerializationTest {
+
+ @Test
+ fun roundTripPreservesPositions() {
+ val original = TreeLayoutResult(
+ mapOf("a" to Point(1.5f, 2.5f), "b" to Point(-3f, 4f)),
+ maxDepth = 3,
+ )
+ val json = original.toJson { it }
+ val restored = TreeLayoutResult.fromJson(json) { it }
+ assertEquals(original.getPosition("a").x, restored.getPosition("a").x)
+ assertEquals(original.getPosition("a").y, restored.getPosition("a").y)
+ assertEquals(original.getPosition("b").x, restored.getPosition("b").x)
+ assertEquals(original.getPosition("b").y, restored.getPosition("b").y)
+ assertEquals(3, restored.getMaxDepth())
+ }
+
+ @Test
+ fun roundTripWithSpecialCharactersInKeys() {
+ val original = TreeLayoutResult(
+ mapOf("node \"A\"" to Point(1f, 2f)),
+ maxDepth = 1,
+ )
+ val json = original.toJson { it }
+ val restored = TreeLayoutResult.fromJson(json) { it }
+ assertEquals(Point(1f, 2f), restored.getPosition("node \"A\""))
+ }
+
+ @Test
+ fun emptyResultSerializes() {
+ val original = TreeLayoutResult(emptyMap(), maxDepth = 0)
+ val json = original.toJson { it }
+ val restored = TreeLayoutResult.fromJson(json) { it }
+ assertEquals(0, restored.getMaxDepth())
+ assertEquals(emptyMap(), restored.getPositions())
+ }
+
+ @Test
+ fun toJsonProducesValidFormat() {
+ val result = TreeLayoutResult(mapOf("x" to Point(1.5f, 2.5f)), maxDepth = 1)
+ val json = result.toJson { it }
+ assertTrue(json.contains("\"maxDepth\":1"))
+ assertTrue(json.contains("\"k\":\"x\""))
+ assertTrue(json.contains("\"px\":1.5"))
+ assertTrue(json.contains("\"py\":2.5"))
+ }
+
+ @Test
+ fun customNodeToKeyMapping() {
+ val result = TreeLayoutResult(mapOf(42 to Point(5f, 6f)), maxDepth = 1)
+ val json = result.toJson { "node_$it" }
+ val restored = TreeLayoutResult.fromJson(json) { it.removePrefix("node_").toInt() }
+ assertEquals(Point(5f, 6f), restored.getPosition(42))
+ }
+
+ private fun assertTrue(condition: Boolean) {
+ assertEquals(true, condition)
+ }
+}
diff --git a/library/src/commonTest/kotlin/io/github/linde9821/treelayout/result/TreeLayoutResultTest.kt b/library/src/commonTest/kotlin/io/github/linde9821/treelayout/result/TreeLayoutResultTest.kt
index 5d2c1aa..7df1c6b 100644
--- a/library/src/commonTest/kotlin/io/github/linde9821/treelayout/result/TreeLayoutResultTest.kt
+++ b/library/src/commonTest/kotlin/io/github/linde9821/treelayout/result/TreeLayoutResultTest.kt
@@ -7,7 +7,7 @@ import kotlin.test.assertEquals
class TreeLayoutResultTest {
private fun result(vararg entries: Pair): TreeLayoutResult =
- MapTreeLayoutResult(entries.toMap(), maxDepth = 1)
+ TreeLayoutResult(entries.toMap(), maxDepth = 1)
@Test
fun getBoundsReturnsCorrectBoundingBox() {
@@ -87,11 +87,42 @@ class TreeLayoutResultTest {
assertEquals(Point(50f, 100f), s.getPosition("b"))
}
+ @Test
+ fun mappedAppliesTransformToAllPositions() {
+ val r = result("a" to Point(1f, 2f), "b" to Point(3f, 4f))
+ val m = r.mapped { Point(it.x * 2f, it.y + 10f) }
+ assertEquals(Point(2f, 12f), m.getPosition("a"))
+ assertEquals(Point(6f, 14f), m.getPosition("b"))
+ }
+
+ @Test
+ fun mappedPreservesMaxDepth() {
+ val r = TreeLayoutResult(mapOf("a" to Point(0f, 0f)), maxDepth = 7)
+ assertEquals(7, r.mapped { it }.getMaxDepth())
+ }
+
@Test
fun maxDepthIsPreservedThroughTransformations() {
- val r = MapTreeLayoutResult(mapOf("a" to Point(0f, 0f)), maxDepth = 5)
+ val r = TreeLayoutResult(mapOf("a" to Point(0f, 0f)), maxDepth = 5)
assertEquals(5, r.normalized().getMaxDepth())
assertEquals(5, r.translated(1f, 1f).getMaxDepth())
assertEquals(5, r.scaledTo(100f, 100f).getMaxDepth())
}
+
+ @Test
+ fun centeredPlacesLayoutInMiddleOfViewport() {
+ // Layout spans 0..10 x 0..20 → scaledTo 100x100 → factor=5 → becomes 50x100
+ // Centered in 100x100: dx = (100-50)/2 = 25, dy = (100-100)/2 = 0
+ val r = result("a" to Point(0f, 0f), "b" to Point(10f, 20f))
+ val c = r.centered(100f, 100f)
+ assertEquals(Point(25f, 0f), c.getPosition("a"))
+ assertEquals(Point(75f, 100f), c.getPosition("b"))
+ }
+
+ @Test
+ fun centeredSingleNodePlacesAtCenter() {
+ val r = result("a" to Point(5f, 5f))
+ val c = r.centered(200f, 100f)
+ assertEquals(Point(100f, 50f), c.getPosition("a"))
+ }
}
\ No newline at end of file
diff --git a/library/src/commonTest/kotlin/io/github/linde9821/treelayout/walker/WalkerTreeLayoutTest.kt b/library/src/commonTest/kotlin/io/github/linde9821/treelayout/walker/WalkerTreeLayoutTest.kt
index fde5c56..e407d5a 100644
--- a/library/src/commonTest/kotlin/io/github/linde9821/treelayout/walker/WalkerTreeLayoutTest.kt
+++ b/library/src/commonTest/kotlin/io/github/linde9821/treelayout/walker/WalkerTreeLayoutTest.kt
@@ -217,6 +217,7 @@ class WalkerTreeLayoutTest {
override fun root(): String = tree.root
override fun children(node: String): List =
tree.childrenMap[node]?.toList() ?: emptyList() // new list each call
+
override fun parent(node: String): String? = tree.parentMap[node]
}
diff --git a/sample/android/build.gradle.kts b/sample/android/build.gradle.kts
index deccf1a..907c8ff 100644
--- a/sample/android/build.gradle.kts
+++ b/sample/android/build.gradle.kts
@@ -21,7 +21,7 @@ android {
}
dependencies {
- implementation("io.github.linde9821:treelayout-kmp:0.3.1")
+ implementation("io.github.linde9821:treelayout-kmp:0.5.0")
implementation("androidx.activity:activity-compose:1.13.0")
implementation("org.jetbrains.compose.runtime:runtime:1.11.0")
implementation("org.jetbrains.compose.foundation:foundation:1.11.0")
diff --git a/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt b/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt
index 38c9e56..4adff73 100644
--- a/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt
+++ b/sample/android/src/main/kotlin/io/github/linde9821/treelayout/sample/android/TreeVisualizationScreen.kt
@@ -1,5 +1,9 @@
package io.github.linde9821.treelayout.sample.android
+import androidx.compose.animation.core.AnimationVector2D
+import androidx.compose.animation.core.TwoWayConverter
+import androidx.compose.animation.core.animateValueAsState
+import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -59,6 +63,11 @@ import kotlin.math.roundToInt
private const val DEFAULT_INPUT: String = "Not all those who wander are lost"
+private val PointToVector = TwoWayConverter(
+ convertToVector = { AnimationVector2D(it.x, it.y) },
+ convertFromVector = { Point(it.v1, it.v2) },
+)
+
private enum class LayoutType { Walker, RadialWalker, DirectAngular }
private class PrefixNode(val label: String, val children: MutableList = mutableListOf())
@@ -119,10 +128,12 @@ internal fun TreeVisualizationScreen() {
var orientationExpanded by remember { mutableStateOf(false) }
var layoutTypeExpanded by remember { mutableStateOf(false) }
- val words = input.lowercase().split("\\s+".toRegex())
- .map { it.filter(Char::isLetter) }
- .filter { it.isNotEmpty() }
- val tree = buildPrefixTree(words)
+ val words = remember(input) {
+ input.lowercase().split("\\s+".toRegex())
+ .map { it.filter(Char::isLetter) }
+ .filter { it.isNotEmpty() }
+ }
+ val tree = remember(words) { buildPrefixTree(words) }
val parentMap = buildMap {
fun walk(node: PrefixNode, parent: PrefixNode?) {
@@ -158,29 +169,51 @@ internal fun TreeVisualizationScreen() {
(textLayouts[node]?.size?.height?.toFloat() ?: 0f) + nodePaddingV * 2
}
- val positions: Map = when (layoutType) {
- LayoutType.Walker -> {
- val config = WalkerLayoutConfiguration(
- horizontalDistance = horizontalDistance,
- verticalDistance = verticalDistance,
- orientation = orientation,
- )
- WalkerTreeLayout(adapter, config, extents).layout().getPositions()
- }
- LayoutType.RadialWalker -> {
- val config = RadialWalkerLayoutConfiguration(
- layerDistance = layerDistance,
- margin = margin,
- rotation = rotation,
- )
- RadialWalkerTreeLayout(adapter, config, extents).layout().getPositions()
+ val targetResult = remember(
+ layoutType, horizontalDistance, verticalDistance, orientation,
+ layerDistance, margin, rotation, tree, nodePaddingH, nodePaddingV,
+ ) {
+ when (layoutType) {
+ LayoutType.Walker -> {
+ val config = WalkerLayoutConfiguration(
+ horizontalDistance = horizontalDistance,
+ verticalDistance = verticalDistance,
+ orientation = orientation,
+ )
+ WalkerTreeLayout(adapter, config, extents).layout()
+ }
+
+ LayoutType.RadialWalker -> {
+ val config = RadialWalkerLayoutConfiguration(
+ layerDistance = layerDistance,
+ margin = margin,
+ rotation = rotation,
+ )
+ RadialWalkerTreeLayout(adapter, config, extents).layout()
+ }
+
+ LayoutType.DirectAngular -> {
+ val config = DirectAngularPlacementConfiguration(
+ layerDistance = layerDistance,
+ rotation = rotation,
+ )
+ DirectAngularPlacementLayout(adapter, config).layout()
+ }
}
- LayoutType.DirectAngular -> {
- val config = DirectAngularPlacementConfiguration(
- layerDistance = layerDistance,
- rotation = rotation,
+ }
+
+ val targetPositions = targetResult.getPositions()
+
+ // Animate each node position
+ val positions: Map = buildMap {
+ for ((node, target) in targetPositions) {
+ val animated by animateValueAsState(
+ targetValue = target,
+ typeConverter = PointToVector,
+ animationSpec = tween(durationMillis = 300),
+ label = "node-pos",
)
- DirectAngularPlacementLayout(adapter, config).layout().getPositions()
+ put(node, animated)
}
}
@@ -220,27 +253,44 @@ internal fun TreeVisualizationScreen() {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text("H Distance: ${horizontalDistance.toInt()}", fontSize = 12.sp)
- Slider(value = horizontalDistance, onValueChange = { horizontalDistance = it }, valueRange = 0f..200f)
+ Slider(
+ value = horizontalDistance,
+ onValueChange = { horizontalDistance = it },
+ valueRange = 0f..200f
+ )
}
Column(modifier = Modifier.weight(1f)) {
Text("V Distance: ${verticalDistance.toInt()}", fontSize = 12.sp)
- Slider(value = verticalDistance, onValueChange = { verticalDistance = it }, valueRange = 0f..200f)
+ Slider(
+ value = verticalDistance,
+ onValueChange = { verticalDistance = it },
+ valueRange = 0f..200f
+ )
}
}
Box {
OutlinedButton(onClick = { orientationExpanded = true }) { Text(orientation.name) }
- DropdownMenu(expanded = orientationExpanded, onDismissRequest = { orientationExpanded = false }) {
+ DropdownMenu(
+ expanded = orientationExpanded,
+ onDismissRequest = { orientationExpanded = false }) {
Orientation.entries.forEach { o ->
- DropdownMenuItem(onClick = { orientation = o; orientationExpanded = false }) { Text(o.name) }
+ DropdownMenuItem(onClick = {
+ orientation = o; orientationExpanded = false
+ }) { Text(o.name) }
}
}
}
}
+
LayoutType.RadialWalker -> {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text("Layer: ${layerDistance.toInt()}", fontSize = 12.sp)
- Slider(value = layerDistance, onValueChange = { layerDistance = it }, valueRange = 10f..200f)
+ Slider(
+ value = layerDistance,
+ onValueChange = { layerDistance = it },
+ valueRange = 10f..200f
+ )
}
Column(modifier = Modifier.weight(1f)) {
Text("Margin: ${(margin * 100).roundToInt() / 100f}", fontSize = 12.sp)
@@ -252,15 +302,24 @@ internal fun TreeVisualizationScreen() {
Slider(value = rotation, onValueChange = { rotation = it }, valueRange = 0f..2f * PI.toFloat())
}
}
+
LayoutType.DirectAngular -> {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text("Layer: ${layerDistance.toInt()}", fontSize = 12.sp)
- Slider(value = layerDistance, onValueChange = { layerDistance = it }, valueRange = 10f..200f)
+ Slider(
+ value = layerDistance,
+ onValueChange = { layerDistance = it },
+ valueRange = 10f..200f
+ )
}
Column(modifier = Modifier.weight(1f)) {
Text("Rotation: ${(rotation * 100).roundToInt() / 100f}", fontSize = 12.sp)
- Slider(value = rotation, onValueChange = { rotation = it }, valueRange = 0f..2f * PI.toFloat())
+ Slider(
+ value = rotation,
+ onValueChange = { rotation = it },
+ valueRange = 0f..2f * PI.toFloat()
+ )
}
}
}
diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt
index f36b853..7c02748 100644
--- a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt
+++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/LayoutControls.kt
@@ -57,6 +57,7 @@ public fun LayoutControls(
LayoutType.Walker -> WalkerControls(state, compact, orientationExpanded) {
orientationExpanded = it
}
+
LayoutType.RadialWalker -> RadialWalkerControls(state, compact)
LayoutType.DirectAngular -> DirectAngularControls(state, compact)
}
@@ -84,7 +85,13 @@ private fun WalkerControls(
SectionLabel("Spacing")
if (compact) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- LabeledSlider("H", state.horizontalDistance, state.onHorizontalDistanceChange, 0f..200f, Modifier.weight(1f))
+ LabeledSlider(
+ "H",
+ state.horizontalDistance,
+ state.onHorizontalDistanceChange,
+ 0f..200f,
+ Modifier.weight(1f)
+ )
LabeledSlider("V", state.verticalDistance, state.onVerticalDistanceChange, 0f..200f, Modifier.weight(1f))
}
} else {
@@ -112,7 +119,14 @@ private fun RadialWalkerControls(state: TreeVisualizationState, compact: Boolean
if (compact) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
LabeledSlider("Layer", state.layerDistance, state.onLayerDistanceChange, 10f..200f, Modifier.weight(1f))
- LabeledSlider("Margin", state.margin, state.onMarginChange, 0f..PI.toFloat(), Modifier.weight(1f), decimals = true)
+ LabeledSlider(
+ "Margin",
+ state.margin,
+ state.onMarginChange,
+ 0f..PI.toFloat(),
+ Modifier.weight(1f),
+ decimals = true
+ )
}
} else {
LabeledSlider("Layer Distance", state.layerDistance, state.onLayerDistanceChange, 10f..200f)
@@ -127,7 +141,14 @@ private fun DirectAngularControls(state: TreeVisualizationState, compact: Boolea
if (compact) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
LabeledSlider("Layer", state.layerDistance, state.onLayerDistanceChange, 10f..200f, Modifier.weight(1f))
- LabeledSlider("Rotation", state.rotation, state.onRotationChange, 0f..2f * PI.toFloat(), Modifier.weight(1f), decimals = true)
+ LabeledSlider(
+ "Rotation",
+ state.rotation,
+ state.onRotationChange,
+ 0f..2f * PI.toFloat(),
+ Modifier.weight(1f),
+ decimals = true
+ )
}
} else {
LabeledSlider("Layer Distance", state.layerDistance, state.onLayerDistanceChange, 10f..200f)
diff --git a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt
index 1389474..8849962 100644
--- a/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt
+++ b/sample/src/commonMain/kotlin/io/github/linde9821/treelayout/sample/TreeVisualizationState.kt
@@ -1,5 +1,9 @@
package io.github.linde9821.treelayout.sample
+import androidx.compose.animation.core.AnimationVector2D
+import androidx.compose.animation.core.TwoWayConverter
+import androidx.compose.animation.core.animateValueAsState
+import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -48,6 +52,11 @@ public class TreeVisualizationState(
public val textLayouts: Map,
)
+private val PointToVector = TwoWayConverter(
+ convertToVector = { AnimationVector2D(it.x, it.y) },
+ convertFromVector = { Point(it.v1, it.v2) },
+)
+
@Composable
public fun rememberTreeVisualizationState(): TreeVisualizationState {
var input by remember { mutableStateOf(DEFAULT_INPUT) }
@@ -61,11 +70,13 @@ public fun rememberTreeVisualizationState(): TreeVisualizationState {
var margin by remember { mutableStateOf(0.5f) }
var rotation by remember { mutableStateOf(0f) }
- val words = input.lowercase().split("\\s+".toRegex())
- .map { it.filter(Char::isLetter) }
- .filter { it.isNotEmpty() }
- val tree = buildPrefixTree(words)
- val adapter = prefixTreeAdapter(tree)
+ val words = remember(input) {
+ input.lowercase().split("\\s+".toRegex())
+ .map { it.filter(Char::isLetter) }
+ .filter { it.isNotEmpty() }
+ }
+ val tree = remember(words) { buildPrefixTree(words) }
+ val adapter = remember(tree) { prefixTreeAdapter(tree) }
val textMeasurer = rememberTextMeasurer()
val textStyle = TextStyle(fontSize = 14.sp, color = Color.Black)
@@ -89,7 +100,7 @@ public fun rememberTreeVisualizationState(): TreeVisualizationState {
(textLayouts[node]?.size?.height?.toFloat() ?: 0f) + nodePaddingV * 2
}
- val positions = when (layoutType) {
+ val targetPositions: Map = when (layoutType) {
LayoutType.Walker -> {
val config = WalkerLayoutConfiguration(
horizontalDistance = horizontalDistance,
@@ -98,6 +109,7 @@ public fun rememberTreeVisualizationState(): TreeVisualizationState {
)
WalkerTreeLayout(adapter, config, extents).layout().getPositions()
}
+
LayoutType.RadialWalker -> {
val config = RadialWalkerLayoutConfiguration(
layerDistance = layerDistance,
@@ -106,6 +118,7 @@ public fun rememberTreeVisualizationState(): TreeVisualizationState {
)
RadialWalkerTreeLayout(adapter, config, extents).layout().getPositions()
}
+
LayoutType.DirectAngular -> {
val config = DirectAngularPlacementConfiguration(
layerDistance = layerDistance,
@@ -115,6 +128,9 @@ public fun rememberTreeVisualizationState(): TreeVisualizationState {
}
}
+ // Animate each node position individually using Compose's animation system.
+ val positions = animatePositions(targetPositions)
+
return TreeVisualizationState(
input = input,
onInputChange = { input = it },
@@ -140,3 +156,21 @@ public fun rememberTreeVisualizationState(): TreeVisualizationState {
textLayouts = textLayouts,
)
}
+
+@Composable
+private fun animatePositions(targets: Map): Map {
+ // We need a stable list of nodes to call animateValueAsState for each.
+ // Since tree is remembered, the node set is stable across parameter changes.
+ val entries = targets.entries.toList()
+ val result = HashMap(entries.size)
+ for ((node, target) in entries) {
+ val animated by animateValueAsState(
+ targetValue = target,
+ typeConverter = PointToVector,
+ animationSpec = tween(durationMillis = 300),
+ label = "node-pos",
+ )
+ result[node] = animated
+ }
+ return result
+}