diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60ab9ab..4104e25 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,25 +24,4 @@ jobs: - uses: gradle/actions/setup-gradle@v4 - name: Build and test - run: ./gradlew :library:check - - samples: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-java@v4 - with: - distribution: zulu - java-version: 17 - - - uses: gradle/actions/setup-gradle@v4 - - - name: Build shared sample - run: ./gradlew :sample:assemble - - - name: Build iOS framework - run: ./gradlew :sample:linkDebugFrameworkIosSimulatorArm64 - - - name: Build Android sample - run: cd sample/android && ./gradlew assembleDebug + run: ./gradlew :library:check \ No newline at end of file diff --git a/README.md b/README.md index 6a06e81..082367d 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,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.3.0") + implementation("io.github.linde9821:treelayout-kmp:0.4.0") } ``` diff --git a/library/build.gradle.kts b/library/build.gradle.kts index c579d69..eff7136 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } group = "io.github.linde9821" -version = "0.3.1" +version = "0.4.0" kotlin { explicitApi() diff --git a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/TreeLayoutResult.kt b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/TreeLayoutResult.kt deleted file mode 100644 index e112e36..0000000 --- a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/TreeLayoutResult.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.github.linde9821.treelayout - -/** - * The result of a tree layout computation, providing access to - * node positions and layout metadata. - * - * @param T The node type of the external tree. - */ -public interface TreeLayoutResult { - /** Returns the position assigned to [node]. */ - public fun getPosition(node: T): Point - - /** Returns all node-to-position mappings. */ - public fun getPositions(): Map - - /** Returns the maximum depth of the tree. */ - public fun getMaxDepth(): Int -} 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 e30036d..7d9164f 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 @@ -2,7 +2,7 @@ package io.github.linde9821.treelayout.radial.angular import io.github.linde9821.treelayout.Point import io.github.linde9821.treelayout.TreeAdapter -import io.github.linde9821.treelayout.TreeLayoutResult +import io.github.linde9821.treelayout.result.TreeLayoutResult import kotlin.math.PI import kotlin.math.cos import kotlin.math.max @@ -77,7 +77,7 @@ public class DirectAngularPlacementLayout( private class DirectAngularResult( private val positions: Map, private val maxDepth: Int, -) : TreeLayoutResult { +) : TreeLayoutResult() { override fun getPosition(node: T): Point = positions[node] ?: throw IllegalArgumentException("Node not part of the layout") 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 28bc402..581069b 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 @@ -3,7 +3,7 @@ package io.github.linde9821.treelayout.radial.walker import io.github.linde9821.treelayout.NodeExtentProvider import io.github.linde9821.treelayout.Point import io.github.linde9821.treelayout.TreeAdapter -import io.github.linde9821.treelayout.TreeLayoutResult +import io.github.linde9821.treelayout.result.TreeLayoutResult import io.github.linde9821.treelayout.walker.WalkerLayoutConfiguration import io.github.linde9821.treelayout.walker.WalkerTreeLayout import kotlin.math.cos @@ -89,7 +89,7 @@ private class UniformNodeExtentProvider : NodeExtentProvider { private class RadialLayoutResult( private val positions: Map, private val maxDepth: Int, -) : TreeLayoutResult { +) : TreeLayoutResult() { override fun getPosition(node: T): Point = positions[node] ?: throw IllegalArgumentException("Node not part of the layout") diff --git a/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/Bounds.kt b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/Bounds.kt new file mode 100644 index 0000000..33bba57 --- /dev/null +++ b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/Bounds.kt @@ -0,0 +1,14 @@ +package io.github.linde9821.treelayout.result + +/** + * Axis-aligned bounding box of a tree layout. + */ +public data class Bounds( + public val minX: Float, + public val minY: Float, + public val maxX: Float, + public val maxY: Float, +) { + public val width: Float get() = maxX - minX + public val height: Float get() = maxY - minY +} \ No newline at end of file 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 new file mode 100644 index 0000000..663d519 --- /dev/null +++ b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/MapTreeLayoutResult.kt @@ -0,0 +1,18 @@ +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/TreeLayoutResult.kt b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/TreeLayoutResult.kt new file mode 100644 index 0000000..ecaecf1 --- /dev/null +++ b/library/src/commonMain/kotlin/io/github/linde9821/treelayout/result/TreeLayoutResult.kt @@ -0,0 +1,71 @@ +package io.github.linde9821.treelayout.result + +import io.github.linde9821.treelayout.Point +import kotlin.math.min + +/** + * The result of a tree layout computation, providing access to + * node positions, layout metadata, and transformation utilities. + * + * @param T The node type of the external tree. + */ +public abstract class TreeLayoutResult { + + /** Returns the position assigned to [node]. */ + public abstract fun getPosition(node: T): Point + + /** Returns all node-to-position mappings. */ + public abstract fun getPositions(): Map + + /** Returns the maximum depth of the tree. */ + public abstract fun getMaxDepth(): Int + + /** 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) + var minX = Float.MAX_VALUE + var minY = Float.MAX_VALUE + var maxX = -Float.MAX_VALUE + var maxY = -Float.MAX_VALUE + for (p in positions) { + if (p.x < minX) minX = p.x + if (p.y < minY) minY = p.y + if (p.x > maxX) maxX = p.x + if (p.y > maxY) maxY = p.y + } + return Bounds(minX, minY, maxX, maxY) + } + + /** Returns a new result with all positions shifted so the minimum corner is at the origin. */ + public fun normalized(): TreeLayoutResult { + val bounds = getBounds() + return translated(-bounds.minX, -bounds.minY) + } + + /** 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()) + } + + /** + * Returns a new result uniformly scaled to fit within [width]×[height], + * preserving aspect ratio. Positions are normalized to origin before scaling. + */ + public fun scaledTo(width: Float, height: Float): TreeLayoutResult { + val bounds = getBounds() + val bw = bounds.width + val bh = bounds.height + val scale = when { + bw == 0f && bh == 0f -> 1f + bw == 0f -> height / bh + 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()) + } +} \ 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 003bcb7..571184e 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 @@ -4,7 +4,7 @@ import io.github.linde9821.treelayout.NodeExtentProvider import io.github.linde9821.treelayout.Orientation import io.github.linde9821.treelayout.Point import io.github.linde9821.treelayout.TreeAdapter -import io.github.linde9821.treelayout.TreeLayoutResult +import io.github.linde9821.treelayout.result.TreeLayoutResult import kotlin.math.max /** @@ -253,7 +253,7 @@ private class LayoutContext( private class LayoutResultImpl( private val positions: Map, private val maxDepth: Int, -) : TreeLayoutResult { +) : TreeLayoutResult() { override fun getPosition(node: T): Point = positions[node] ?: throw IllegalArgumentException("Node not part of the layout") 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 new file mode 100644 index 0000000..5d2c1aa --- /dev/null +++ b/library/src/commonTest/kotlin/io/github/linde9821/treelayout/result/TreeLayoutResultTest.kt @@ -0,0 +1,97 @@ +package io.github.linde9821.treelayout.result + +import io.github.linde9821.treelayout.Point +import kotlin.test.Test +import kotlin.test.assertEquals + +class TreeLayoutResultTest { + + private fun result(vararg entries: Pair): TreeLayoutResult = + MapTreeLayoutResult(entries.toMap(), maxDepth = 1) + + @Test + fun getBoundsReturnsCorrectBoundingBox() { + val r = result("a" to Point(-5f, 2f), "b" to Point(10f, 8f), "c" to Point(3f, -1f)) + val b = r.getBounds() + assertEquals(-5f, b.minX) + assertEquals(-1f, b.minY) + assertEquals(10f, b.maxX) + assertEquals(8f, b.maxY) + assertEquals(15f, b.width) + assertEquals(9f, b.height) + } + + @Test + fun getBoundsEmptyPositionsReturnsZero() { + val r = result() + val b = r.getBounds() + assertEquals(Bounds(0f, 0f, 0f, 0f), b) + } + + @Test + fun normalizedShiftsMinCornerToOrigin() { + val r = result("a" to Point(5f, 10f), "b" to Point(15f, 30f)) + val n = r.normalized() + assertEquals(Point(0f, 0f), n.getPosition("a")) + assertEquals(Point(10f, 20f), n.getPosition("b")) + } + + @Test + fun translatedShiftsAllPositions() { + val r = result("a" to Point(1f, 2f), "b" to Point(3f, 4f)) + val t = r.translated(10f, -5f) + assertEquals(Point(11f, -3f), t.getPosition("a")) + assertEquals(Point(13f, -1f), t.getPosition("b")) + } + + @Test + fun scaledToFitsWithinTargetDimensions() { + // Layout spans 0..100 x 0..50 → scale to 200x200 → limited by width ratio (2.0) vs height ratio (4.0) → uses 2.0 + val r = result("a" to Point(0f, 0f), "b" to Point(100f, 50f)) + val s = r.scaledTo(200f, 200f) + assertEquals(Point(0f, 0f), s.getPosition("a")) + assertEquals(Point(200f, 100f), s.getPosition("b")) + } + + @Test + fun scaledToPreservesAspectRatio() { + // Layout spans 0..10 x 0..20 → scale to 100x100 → limited by height (5.0) vs width (10.0) → uses 5.0 + val r = result("a" to Point(0f, 0f), "b" to Point(10f, 20f)) + val s = r.scaledTo(100f, 100f) + assertEquals(Point(0f, 0f), s.getPosition("a")) + assertEquals(Point(50f, 100f), s.getPosition("b")) + } + + @Test + fun scaledToNormalizesBeforeScaling() { + // Layout at offset: 10..30 x 5..15 → width=20, height=10 → scale to 40x40 → factor=2.0 + val r = result("a" to Point(10f, 5f), "b" to Point(30f, 15f)) + val s = r.scaledTo(40f, 40f) + assertEquals(Point(0f, 0f), s.getPosition("a")) + assertEquals(Point(40f, 20f), s.getPosition("b")) + } + + @Test + fun scaledToSingleNodeReturnsOrigin() { + val r = result("a" to Point(42f, 99f)) + val s = r.scaledTo(800f, 600f) + assertEquals(Point(0f, 0f), s.getPosition("a")) + } + + @Test + fun chainingNormalizedAndScaledTo() { + val r = result("a" to Point(-10f, -20f), "b" to Point(10f, 20f)) + val s = r.normalized().scaledTo(100f, 100f) + // After normalized: a=(0,0), b=(20,40). scaledTo 100x100 → factor=min(5, 2.5)=2.5 + assertEquals(Point(0f, 0f), s.getPosition("a")) + assertEquals(Point(50f, 100f), s.getPosition("b")) + } + + @Test + fun maxDepthIsPreservedThroughTransformations() { + val r = MapTreeLayoutResult(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()) + } +} \ No newline at end of file diff --git a/sample/android/build.gradle.kts b/sample/android/build.gradle.kts index 64a54bd..deccf1a 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.0") + implementation("io.github.linde9821:treelayout-kmp:0.3.1") 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/build.gradle.kts b/sample/build.gradle.kts index 229788f..8c21193 100644 --- a/sample/build.gradle.kts +++ b/sample/build.gradle.kts @@ -24,10 +24,10 @@ kotlin { sourceSets { commonMain.dependencies { - implementation("org.jetbrains.compose.runtime:runtime:1.10.3") - implementation("org.jetbrains.compose.foundation:foundation:1.10.3") - implementation("org.jetbrains.compose.material:material:1.10.3") - implementation("org.jetbrains.compose.ui:ui:1.10.3") + implementation("org.jetbrains.compose.runtime:runtime:1.11.0") + implementation("org.jetbrains.compose.foundation:foundation:1.11.0") + implementation("org.jetbrains.compose.material:material:1.11.0") + implementation("org.jetbrains.compose.ui:ui:1.11.0") implementation(project(":library")) } commonTest.dependencies {