From 903eb578dabcd15047e7fadf07de3f3bbd096416 Mon Sep 17 00:00:00 2001 From: Steven Jeuris Date: Thu, 29 May 2025 11:54:32 +0200 Subject: [PATCH 1/3] Add `Interval.getPercentageFor()` --- README.md | 17 +++++++ .../src/commonTest/kotlin/Readme.kt | 21 +++++++++ .../src/commonMain/kotlin/IntervalTest.kt | 47 +++++++++++++++---- .../src/commonMain/kotlin/Interval.kt | 9 ++++ 4 files changed, 86 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0af8165..eb8fd32 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,22 @@ val size: Duration = interval.size // 100 seconds val shifted = interval shr 24.hours // 100 seconds 24 hours from now ``` +Intervals support common math operations which allow concisely expressing common use cases. + +```kotlin +// Two intervals of different types: a timeline visualized at some screen coordinates. +val start2025 = LocalDateTime( 2025, 1, 1, 0, 0 ).toInstant( TimeZone.UTC ) +val end2025 = LocalDateTime( 2026, 1, 1, 0, 0 ).toInstant( TimeZone.UTC ) +val year2025: InstantInterval = interval( start2025, end2025 ) +val timelineUi: IntInterval = interval( 0, 800 ) // UI element 800 pixels wide + +// Find the selected time at a given UI coordinate using linear interpolation. +val mouseX = 400 +val uiPercentage: Double = timelineUi.getPercentageFor( mouseX ) +val selectedTime: Instant = year2025.getValueAt( uiPercentage ) // July 2nd at noon. +``` + + ## Interval Unions Intervals are a subset of _interval unions_, which represent a collection of intervals. @@ -92,6 +108,7 @@ The following operations are specific to `Interval`: | `isLowerBoundIncluded`, `isUpperBoundIncluded` | Corresponds to `isStartIncluded` and `isEndIncluded`, but swapped if `isReversed`. | | `size` | The absolute difference between `start` and `end`. | | `getValueAt()` | Get the value at a given percentage inside (0.0–1.0) or outside (< 0.0, > 1.0) the interval. | +| `getPercentageFor()` | Gets the percentage how far within (0.0-1.0) or outside (< 0.0, > 1.0) of the interval a value lies. | | `nonReversed()` | `reverse()` the interval in case it `isReversed`. | | `reverse()` | Return an interval which swaps `start` with `end`, as well as boundary inclusions. | | `canonicalize()` | Return the interval in canonical form. E.g., The canonical form of `[5, 1)` is `[2, 5]` for integer types. | diff --git a/kotlinx.interval.datetime/src/commonTest/kotlin/Readme.kt b/kotlinx.interval.datetime/src/commonTest/kotlin/Readme.kt index 69d8c83..be285d7 100644 --- a/kotlinx.interval.datetime/src/commonTest/kotlin/Readme.kt +++ b/kotlinx.interval.datetime/src/commonTest/kotlin/Readme.kt @@ -2,7 +2,13 @@ package io.github.whathecode.kotlinx.interval.datetime +import io.github.whathecode.kotlinx.interval.IntInterval +import io.github.whathecode.kotlinx.interval.interval import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant import kotlin.test.* import kotlin.time.Duration import kotlin.time.Duration.Companion.hours @@ -20,4 +26,19 @@ class Readme val size: Duration = interval.size // 100 seconds val shifted = interval shr 24.hours // 100 seconds 24 hours from now } + + @Test + fun introduction_common_math() + { + // Two intervals of different types. + val start2025 = LocalDateTime( 2025, 1, 1, 0, 0 ).toInstant( TimeZone.UTC ) + val end2025 = LocalDateTime( 2026, 1, 1, 0, 0 ).toInstant( TimeZone.UTC ) + val year2025: InstantInterval = interval( start2025, end2025 ) + val timelineUi: IntInterval = interval( 0, 800 ) // UI element 800 pixels wide + + // Find the selected time at a given UI coordinate using linear interpolation. + val mouseX = 400 + val uiPercentage: Double = timelineUi.getPercentageFor( mouseX ) + val selectedTime: Instant = year2025.getValueAt( uiPercentage ) // July 2nd at noon. + } } diff --git a/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt b/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt index 48953e2..a563bc1 100644 --- a/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt +++ b/kotlinx.interval.testcases/src/commonMain/kotlin/IntervalTest.kt @@ -196,16 +196,10 @@ abstract class IntervalTest, TSize : Comparable>( fun getValueAt_outside_interval() { val abIntervals = createAllInclusionTypeIntervals( a, b ) - for ( ab in abIntervals ) - { - assertEquals( c, ab.getValueAt( 2.0 ) ) - } + for ( ab in abIntervals ) assertEquals( c, ab.getValueAt( 2.0 ) ) val bcIntervals = createAllInclusionTypeIntervals( b, c ) - for ( bc in bcIntervals ) - { - assertEquals( a, bc.getValueAt( -1.0 ) ) - } + for ( bc in bcIntervals ) assertEquals( a, bc.getValueAt( -1.0 ) ) } @Test @@ -255,6 +249,43 @@ abstract class IntervalTest, TSize : Comparable>( assertFailsWith { ab.getValueAt( tooBigPercentage ) } } + @Test + fun getPercentageFor_inside_interval() + { + val acIntervals = createAllInclusionTypeIntervals( a, c ) + + for ( ac in acIntervals ) + { + assertEquals( 0.0, ac.getPercentageFor( a ) ) + assertEquals( 0.5, ac.getPercentageFor( b ) ) + assertEquals( 1.0, ac.getPercentageFor( c ) ) + } + } + + @Test + fun getPercentageFor_outside_interval() + { + val bcIntervals = createAllInclusionTypeIntervals( b, c ) + for ( bc in bcIntervals ) assertEquals( -1.0, bc.getPercentageFor( a ) ) + + val abIntervals = createAllInclusionTypeIntervals( a, b ) + for ( ab in abIntervals ) assertEquals( 2.0, ab.getPercentageFor( c ) ) + } + + @Test + fun getPercentageFor_reverse_intervals() + { + val adIntervals = createAllInclusionTypeIntervals( a, d ) + for ( ad in adIntervals ) + { + // a, b, c, and d all lie the same distance apart, so b and c are 1/4th of the bounds away. + val nonReversed = ad.getPercentageFor( b ) + assertEquals( 0.333, nonReversed, absoluteTolerance = 0.001 ) + val reversed = ad.reverse().getPercentageFor( b ) + assertEquals( 0.666, reversed, absoluteTolerance = 0.001 ) + } + } + @Test fun minus_for_interval_lying_within() { diff --git a/kotlinx.interval/src/commonMain/kotlin/Interval.kt b/kotlinx.interval/src/commonMain/kotlin/Interval.kt index d7bbb02..575abf7 100644 --- a/kotlinx.interval/src/commonMain/kotlin/Interval.kt +++ b/kotlinx.interval/src/commonMain/kotlin/Interval.kt @@ -150,6 +150,15 @@ open class Interval, TSize : Comparable>( return operations.unsafeShift( lowerBound, addToLowerBoundSize, shiftLeft ) } + fun getPercentageFor( value: T ): Double + { + val valueDouble = valueOperations.toDouble( value ) + val startDouble = valueOperations.toDouble( start ) + val endDouble = valueOperations.toDouble( end ) + + return (valueDouble - startDouble) / (endDouble - startDouble) + } + /** * Return an [IntervalUnion] representing all [T] values in this interval, * excluding all [T] values in the specified interval [toSubtract]. From 6d83eb94fd8f9bfd2b3253c6b2b5aae5bc048449 Mon Sep 17 00:00:00 2001 From: Steven Jeuris Date: Thu, 29 May 2025 14:24:35 +0200 Subject: [PATCH 2/3] Refactor: simplified `getValueAt()` implementation There was no need to rely on TSize operations, as values of T could directly be converted to doubles. This is clearer, and in all likelihood also more performant. --- .../src/commonMain/kotlin/Interval.kt | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/kotlinx.interval/src/commonMain/kotlin/Interval.kt b/kotlinx.interval/src/commonMain/kotlin/Interval.kt index 575abf7..d9031f5 100644 --- a/kotlinx.interval/src/commonMain/kotlin/Interval.kt +++ b/kotlinx.interval/src/commonMain/kotlin/Interval.kt @@ -131,23 +131,28 @@ open class Interval, TSize : Comparable>( */ fun getValueAt( percentage: Double ): T { + val lowerBoundDouble = valueOperations.toDouble( lowerBound ) + val upperBoundDouble = valueOperations.toDouble( upperBound ) + val size = upperBoundDouble - lowerBoundDouble + val normalizedPercentage = if ( isReversed ) 1.0 - percentage else percentage - val shiftLeft = normalizedPercentage < 0 - val absPercentage = if ( shiftLeft ) -normalizedPercentage else normalizedPercentage - val addToLowerBoundDouble = sizeOperations.toDouble( size ) * absPercentage + val valueDouble = lowerBoundDouble + (normalizedPercentage * size) // Throw when the resulting value can't be represented by T. - if ( shiftLeft || normalizedPercentage > 1 ) + if ( normalizedPercentage < 0.0 ) + { + val min = valueOperations.toDouble( operations.minValue ) + if ( valueDouble < min ) + throw ArithmeticException( "The resulting value $valueDouble is out of bounds for this type." ) + } + else if ( normalizedPercentage > 1.0 ) { - val max = if ( shiftLeft ) operations.minValue else operations.maxValue - val toMax = operations.getDistance( lowerBound, max ) - val toMaxDouble = sizeOperations.toDouble( toMax ) - if ( addToLowerBoundDouble > toMaxDouble ) - throw ArithmeticException( "The resulting value is out of bounds for this type." ) + val max = valueOperations.toDouble( operations.maxValue ) + if ( valueDouble > max ) + throw ArithmeticException( "The resulting value $valueDouble is out of bounds for this type." ) } - val addToLowerBoundSize = sizeOperations.fromDouble( addToLowerBoundDouble ) - return operations.unsafeShift( lowerBound, addToLowerBoundSize, shiftLeft ) + return valueOperations.fromDouble( valueDouble ) } fun getPercentageFor( value: T ): Double From fe70072683aea7f6559a4f9b6f9ad96173bc8f5b Mon Sep 17 00:00:00 2001 From: Steven Jeuris Date: Thu, 29 May 2025 19:35:49 +0200 Subject: [PATCH 3/3] Build: upgrade to datetime 0.6.2 --- kotlinx.interval.datetime/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kotlinx.interval.datetime/build.gradle.kts b/kotlinx.interval.datetime/build.gradle.kts index 06983e0..f9996c9 100644 --- a/kotlinx.interval.datetime/build.gradle.kts +++ b/kotlinx.interval.datetime/build.gradle.kts @@ -19,7 +19,7 @@ kotlin { commonMain { dependencies { api(project(":kotlinx-interval")) - implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") } } commonTest {