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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 1 addition & 22 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
```

Expand Down
2 changes: 1 addition & 1 deletion library/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ plugins {
}

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

kotlin {
explicitApi()
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,7 +77,7 @@ public class DirectAngularPlacementLayout<T>(
private class DirectAngularResult<T>(
private val positions: Map<T, Point>,
private val maxDepth: Int,
) : TreeLayoutResult<T> {
) : TreeLayoutResult<T>() {
override fun getPosition(node: T): Point =
positions[node] ?: throw IllegalArgumentException("Node not part of the layout")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -89,7 +89,7 @@ private class UniformNodeExtentProvider<T> : NodeExtentProvider<T> {
private class RadialLayoutResult<T>(
private val positions: Map<T, Point>,
private val maxDepth: Int,
) : TreeLayoutResult<T> {
) : TreeLayoutResult<T>() {
override fun getPosition(node: T): Point =
positions[node] ?: throw IllegalArgumentException("Node not part of the layout")

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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<T>(
private val positions: Map<T, Point>,
private val maxDepth: Int,
) : TreeLayoutResult<T>() {
override fun getPosition(node: T): Point =
positions[node] ?: throw IllegalArgumentException("Node not part of the layout")

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

override fun getMaxDepth(): Int = maxDepth
}
Original file line number Diff line number Diff line change
@@ -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<T> {

/** Returns the position assigned to [node]. */
public abstract fun getPosition(node: T): Point

/** Returns all node-to-position mappings. */
public abstract fun getPositions(): Map<T, Point>

/** 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<T> {
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<T> {
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<T> {
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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -253,7 +253,7 @@ private class LayoutContext<T>(
private class LayoutResultImpl<T>(
private val positions: Map<T, Point>,
private val maxDepth: Int,
) : TreeLayoutResult<T> {
) : TreeLayoutResult<T>() {
override fun getPosition(node: T): Point =
positions[node] ?: throw IllegalArgumentException("Node not part of the layout")

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Point>): TreeLayoutResult<String> =
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())
}
}
2 changes: 1 addition & 1 deletion sample/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
8 changes: 4 additions & 4 deletions sample/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading