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 [![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. @@ -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 +}