From 3627b5f77a5fb7354b7259d4d84a26e4f69766cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Garc=C3=ADa=20Garz=C3=B3n?= Date: Mon, 8 Dec 2025 15:34:40 +0100 Subject: [PATCH 1/9] Add HeatMapPlot --- .../koalaplot/core/heatmap/HeatMapPlot.kt | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt new file mode 100644 index 000000000..e68a50f1d --- /dev/null +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt @@ -0,0 +1,91 @@ +package io.github.koalaplot.core.heatmap + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import io.github.koalaplot.core.animation.StartAnimationUseCase +import io.github.koalaplot.core.style.KoalaPlotTheme +import io.github.koalaplot.core.xygraph.Point +import io.github.koalaplot.core.xygraph.XYGraphScope +import kotlin.math.max +import kotlin.math.min + +public typealias HeatMapGrid = Array> + +@Composable +public fun , Y : Comparable, Z> XYGraphScope.HeatMapPlot( + xDomain: ClosedRange, + yDomain: ClosedRange, + bins: HeatMapGrid, + colorScale: (Z) -> Color, + alphaScale: (Z) -> Float = { 1f }, + animationSpec: AnimationSpec = KoalaPlotTheme.animationSpec, +) { + if (bins.isEmpty() || bins[0].isEmpty()) return + + val beta = remember { Animatable(0f) } + LaunchedEffect(null) { beta.animateTo(1f, animationSpec = animationSpec) } + + val xBins = bins.size + val yBins = bins[0].size + + Canvas(modifier = Modifier.fillMaxSize()) { + fun mapX(x: X): Float = xAxisModel.computeOffset(x) * size.width + + fun mapY(y: Y): Float = yAxisModel.computeOffset(y) * size.height + + fun > sortPair( + a: T, + b: T, + ): Pair = if (a <= b) a to b else b to a + + val (left, right) = sortPair( + mapX(xDomain.start), + mapX(xDomain.endInclusive), + ) + val (top, bottom) = sortPair( + mapY(yDomain.start), + mapY(yDomain.endInclusive), + ) + + // Pre-calculate cell size + val cellWidth = (right - left) / xBins + val cellHeight = (bottom - top) / yBins + val cellSize = Size( + beta.value * cellWidth, + beta.value * cellHeight, + ) + val animationOffset = (1f - beta.value) / 2f + + for (xi in 0 until xBins) { + for (yi in 0 until yBins) { + val value = bins[xi][yi] ?: continue + + val alpha = alphaScale(value) * beta.value + if (alpha <= 0f) continue + + val cellColor = colorScale(value) + val cellLeft = left + (xi + animationOffset) * cellWidth + val cellTop = top + (yi + animationOffset) * cellHeight + + drawRect( + color = cellColor, + topLeft = Offset(cellLeft, cellTop), + size = cellSize, + alpha = alpha, + ) + } + } + } +} From d488ad792389328875103f90c2e6e980c778eb79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Garc=C3=ADa=20Garz=C3=B3n?= Date: Tue, 9 Dec 2025 01:53:28 +0100 Subject: [PATCH 2/9] Added ColorScales for HeatMap --- .../koalaplot/core/heatmap/ColorScales.kt | 115 +++++++ .../koalaplot/core/heatmap/ColorScalesTest.kt | 315 ++++++++++++++++++ 2 files changed, 430 insertions(+) create mode 100644 src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt create mode 100644 src/desktopTest/kotlin/io/github/koalaplot/core/heatmap/ColorScalesTest.kt diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt new file mode 100644 index 000000000..eb86ecb18 --- /dev/null +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt @@ -0,0 +1,115 @@ +package io.github.koalaplot.core.heatmap + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp + +public typealias ColorScale = (Z) -> Color + +/** + * Creates a linear color scale that interpolates between colors. + * @param domain Range of values to map + * @param colors List of colors to interpolate between + */ +public fun linearColorScale( + domain: ClosedRange, + colors: List, +): ColorScale where Z : Comparable, Z : Number = { value -> + val normalized = ( + (value.toFloat() - domain.start.toFloat()) / + (domain.endInclusive.toFloat() - domain.start.toFloat()) + ).coerceIn(0f, 1f) + + if (colors.size == 1) { + colors[0] + } else { + val segmentSize = 1f / (colors.size - 1) + val segmentIndex = (normalized / segmentSize).toInt().coerceAtMost(colors.size - 2) + val segmentProgress = (normalized - segmentIndex * segmentSize) / segmentSize + + lerp(colors[segmentIndex], colors[segmentIndex + 1], segmentProgress) + } +} + +/** + * Creates a diverging color scale with a neutral midpoint. + * @param domain Range of values to map + * @param lowColor Color for low values + * @param midColor Color for midpoint values + * @param highColor Color for high values + */ +public fun divergingColorScale( + domain: ClosedRange, + lowColor: Color = Color.Blue, + midColor: Color = Color.White, + highColor: Color = Color.Red, +): ColorScale where Z : Comparable, Z : Number = { value -> + val normalized = ( + (value.toFloat() - domain.start.toFloat()) / + (domain.endInclusive.toFloat() - domain.start.toFloat()) + ).coerceIn(0f, 1f) + + if (normalized < 0.5f) { + val progress = normalized * 2f + lerp(lowColor, midColor, progress) + } else { + val progress = (normalized - 0.5f) * 2f + lerp(midColor, highColor, progress) + } +} + +/** + * Creates a discrete color scale that maps values to specific colors. + * @param thresholds List of threshold values (ascending) + * @param colors List of colors (same length as thresholds + 1) + */ +public fun discreteColorScale( + thresholds: List, + colors: List, +): ColorScale where Z : Comparable { + require(colors.size == thresholds.size + 1) { + "There should be one more color (now ${colors.size}) " + + "than thresholds (${thresholds.size})" + } + return { value -> + val index = thresholds.indexOfFirst { it > value } + if (index < 0) { + colors.last() + } else { + colors[index] + } + } +} + +/** + * Creates a discrete color scale with automatic binning. + * @param domain Range of values to map + * @param binCount Number of discrete bins + * @param colors List of colors for each bin + */ +public fun discreteColorScale( + domain: ClosedRange, + colors: List, +): ColorScale where Z : Comparable, Z : Number { + require(colors.size >= 1) { "Scale needs at least one color" } + val binCount = colors.size + + val startFloat = domain.start.toFloat() + val endFloat = domain.endInclusive.toFloat() + val binSize = (endFloat - startFloat) / binCount + val thresholds = (1 until binCount).map { i -> + when (domain.start) { + is Int -> (startFloat + i * binSize).toInt() as Z + is Float -> (startFloat + i * binSize) as Z + is Double -> (startFloat + i * binSize) as Z + is Long -> (startFloat + i * binSize).toLong() as Z + is Short -> (startFloat + i * binSize).toInt().toShort() as Z + is Byte -> (startFloat + i * binSize).toInt().toByte() as Z + else -> throw UnsupportedOperationException("Unsupported numeric type: ${domain.start::class}") + } + } + + return discreteColorScale( + thresholds, + colors, + ) +} diff --git a/src/desktopTest/kotlin/io/github/koalaplot/core/heatmap/ColorScalesTest.kt b/src/desktopTest/kotlin/io/github/koalaplot/core/heatmap/ColorScalesTest.kt new file mode 100644 index 000000000..289b85f6b --- /dev/null +++ b/src/desktopTest/kotlin/io/github/koalaplot/core/heatmap/ColorScalesTest.kt @@ -0,0 +1,315 @@ +@file:Suppress("MagicNumber") + +package io.github.koalaplot.core.heatmap + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.toArgb +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Custom assertion helper for exact color comparisons using ARGB hex conversion + */ +private fun assertColorEquals( + expected: Color, + actual: Color, + message: String? = null, +) { + val expectedHex = String.format("0x%08X", expected.toArgb()) + val actualHex = String.format("0x%08X", actual.toArgb()) + + if (message != null) { + assertEquals(expectedHex, actualHex, message) + } else { + assertEquals(expectedHex, actualHex) + } +} + +private fun assertScaleValues( + scale: ColorScale, + expected: List>, + message: String? = null, +) { + fun formatColor(c: Color) = String.format("0x%08X", c.toArgb()) + + val formatedExpected = expected.map { (value, color) -> + value to formatColor(color) + } + val result = expected.map { (value, color) -> + value to formatColor(scale(value)) + } + if (message != null) { + assertEquals(formatedExpected.toString(), result.toString(), message) + } else { + assertEquals(formatedExpected.toString(), result.toString()) + } +} + +val todoColor = Color(0x0) + +class ColorScalesTest { + @Test + fun testLinearColorScaleWithFloat() { + val colors = listOf(Color.Red, Color.Blue, Color.Green) + val scale = linearColorScale(0f..10f, colors) + + // Test start of domain + assertColorEquals(Color.Red, scale(0f), "Start of domain should return Red") + + // Test end of domain + assertColorEquals(Color.Green, scale(10f), "End of domain should return Green") + + // Test middle point (should be exactly Blue) + assertColorEquals(Color.Blue, scale(5f)) + + // Test basic interpolation - check that it's not the same as start or end + val quarterColor = scale(2.5f) + assertColorEquals(Color(0xFF8C53A2), quarterColor, "Quarter color should be 25% between Red and Blue") + + // Test interpolation between Blue and Green + val threeQuarterColor = scale(7.5f) + assertColorEquals(Color(0xFF00AABF), threeQuarterColor, "Three quarter color should be 50% between Blue and Green") + } + + @Test + fun testLinearColorScaleWithInt() { + val colors = listOf(Color.Red, Color.Green) + val scale = linearColorScale(0..100, colors) + + assertColorEquals(Color.Red, scale(0)) + assertColorEquals(Color.Green, scale(100)) + + // Test middle value + val midColor = scale(50) + assertColorEquals(Color(0xFFD0A800), midColor, "Middle color should be 50% between Yellow and Cyan") + } + + @Test + fun testLinearColorScaleWithDouble() { + val colors = listOf(Color.Yellow, Color.Cyan) + val scale = linearColorScale(0.0..1.0, colors) + + assertColorEquals(Color.Yellow, scale(0.0)) + assertColorEquals(Color.Cyan, scale(1.0)) + + // Test middle value + val midColor = scale(0.5) + assertColorEquals(Color(0xFFB0FFB0), midColor, "Middle color should be 50% between Yellow and Cyan") + } + + @Test + fun testLinearColorScaleWithSingleColor() { + val colors = listOf(Color.Magenta) + val scale = linearColorScale(0f..100f, colors) + + // Should always return single color regardless of input + assertColorEquals(Color.Magenta, scale(0f)) + assertColorEquals(Color.Magenta, scale(50f)) + assertColorEquals(Color.Magenta, scale(100f)) + } + + @Test + fun testLinearColorScaleOutOfBounds() { + val colors = listOf(Color.Red, Color.Blue) + val scale = linearColorScale(10f..20f, colors) + + // Values below domain should be clamped to first color + assertColorEquals(Color.Red, scale(0f)) + assertColorEquals(Color.Red, scale(5f)) + + // Values above domain should be clamped to last color + assertColorEquals(Color.Blue, scale(25f)) + assertColorEquals(Color.Blue, scale(100f)) + } + + @Test + fun testLinearColorScaleWithNegativeDomain() { + val colors = listOf(Color.Blue, Color.Red) + val scale = linearColorScale(-10f..10f, colors) + + assertColorEquals(Color.Blue, scale(-10f)) + assertColorEquals(Color.Red, scale(10f)) + assertColorEquals(Color.Blue, scale(-20f)) // Below domain + assertColorEquals(Color.Red, scale(20f)) // Above domain + + assertColorEquals(Color(0xFF8C53A2), scale(0f)) + } + + @Test + fun testDivergingColorScaleDefaultColors() { + val scale = divergingColorScale(-1f..1f) + + assertColorEquals(Color.Blue, scale(-1f)) + assertColorEquals(Color.White, scale(0f)) + assertColorEquals(Color.Red, scale(1f)) + + // Test interpolation in negative/blue range + val negMidColor = scale(-0.5f) + assertColorEquals(Color(0xFF74A3FF), negMidColor, "Should be a White to Blue interpolation") + + // Test interpolation in positive/red range + val posMidColor = scale(0.5f) + assertColorEquals(Color(0xFFFFA191), posMidColor, "Should be a White to Red interpolation") + } + + @Test + fun testDivergingColorScaleCustomColors() { + val scale = divergingColorScale( + domain = 0f..100f, + lowColor = Color.Green, + midColor = Color.Yellow, + highColor = Color.Red, + ) + + assertColorEquals(Color.Green, scale(0f)) + assertColorEquals(Color.Yellow, scale(50f)) + assertColorEquals(Color.Red, scale(100f)) + } + + @Test + fun testDivergingColorScaleOutOfBounds() { + val scale = divergingColorScale(0f..10f) + + // Below domain should be clamped to low color + assertColorEquals(Color.Blue, scale(-5f)) + + // Above domain should be clamped to high color + assertColorEquals(Color.Red, scale(15f)) + } + + @Test + fun testDiscreteColorScaleWithThresholds() { + val thresholds = listOf(10, 20, 30) + val colors = listOf(Color.Red, Color.Yellow, Color.Green, Color.Blue) + val scale = discreteColorScale(thresholds, colors) + + assertScaleValues( + scale, + listOf( + -1 to Color.Red, + 9 to Color.Red, + 10 to Color.Yellow, + 11 to Color.Yellow, + 19 to Color.Yellow, + 20 to Color.Green, + 21 to Color.Green, + 29 to Color.Green, + 30 to Color.Blue, + 31 to Color.Blue, + 100 to Color.Blue, + ), + ) + } + + @Test + fun testDiscreteColorScaleWithFloatThresholds() { + val thresholds = listOf(0.25f, 0.5f, 0.75f) + val colors = listOf(Color.Red, Color.Yellow, Color.Green, Color.Blue) + val scale = discreteColorScale(thresholds, colors) + + assertScaleValues( + scale, + listOf( + 0.24f to Color.Red, + 0.25f to Color.Yellow, + 0.26f to Color.Yellow, + 0.40f to Color.Yellow, + 0.50f to Color.Green, + 0.74f to Color.Green, + 0.75f to Color.Blue, + 0.76f to Color.Blue, + ), + ) + } + + @Test + fun testDiscreteColorScaleWithAutomaticBinning() { + val colors = listOf(Color.Red, Color.Yellow, Color.Green, Color.Blue) + val scale = discreteColorScale( + domain = 0f..100f, + colors = colors, + ) + + assertScaleValues( + scale, + listOf( + -1f to Color.Red, + 24f to Color.Red, + 25f to Color.Yellow, + 26f to Color.Yellow, + 49f to Color.Yellow, + 50f to Color.Green, + 51f to Color.Green, + 74f to Color.Green, + 75f to Color.Blue, + 76f to Color.Blue, + 100f to Color.Blue, + ), + ) + } + + @Test + fun testDiscreteColorScaleWithAutomaticBinningInt() { + val colors = listOf(Color.Red, Color.Blue, Color.Green) + val scale = discreteColorScale( + domain = 1..10, + colors = colors, + ) + + assertScaleValues( + scale, + listOf( + 0 to Color.Red, + 2 to Color.Red, + 3 to Color.Red, + 4 to Color.Blue, + 6 to Color.Blue, + 7 to Color.Green, + 10 to Color.Green, + ), + ) + } + + @Test + fun testDiscreteColorScaleInsufficientColors() { + val exception = assertFailsWith { + discreteColorScale( + domain = 0f..100f, + colors = emptyList(), + ) + } + assertEquals("Scale needs at least one color", exception.message) + } + + @Test + fun testDiscreteColorScaleMissmatchedThresholds() { + val exception = assertFailsWith { + discreteColorScale( + // 5 thresholds + thresholds = listOf(1, 2, 3, 4, 5), + // 3 colors (should be 4) + colors = listOf(Color.Red, Color.Green, Color.Blue), + ) + } + assertEquals("There should be one more color (now 3) than thresholds (5)", exception.message) + } + + @Test + fun testColorScaleWithCustomTransform() { + val inverseScale = linearColorScale( + 1..100, + listOf(Color.Red, Color.Blue), + ) + + assertScaleValues( + inverseScale, + listOf( + 1 to Color.Red, + 100 to Color.Blue, + ), + ) + } +} From 7e1a9b07cd08351b6b33f57f6deb84c55792a063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Garc=C3=ADa=20Garz=C3=B3n?= Date: Tue, 9 Dec 2025 09:53:28 +0100 Subject: [PATCH 3/9] Add Histogram2D --- .../koalaplot/core/heatmap/Histogram2D.kt | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt new file mode 100644 index 000000000..8e544af5d --- /dev/null +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt @@ -0,0 +1,51 @@ +package io.github.koalaplot.core.heatmap + +/** + * Generates a 2D histogram from a list of samples. + * + * Creates a 2D grid where each cell contains the count of samples that fall within that cell's boundaries. + * Samples outside the specified domains are ignored (clipped). + * + * @param T The type of data points being processed + * @param X The numeric type for x-coordinates + * @param Y The numeric type for y-coordinates + * @param samples List of data points to histogram + * @param nBinsX Number of bins along x-axis + * @param nBinsY Number of bins along y-axis + * @param xDomain Range of x-values to include in histogram + * @param yDomain Range of y-values to include in histogram + * @param xGetter Function to extract x-coordinate from a sample + * @param yGetter Function to extract y-coordinate from a sample + * @return HeatMapGrid containing the histogram counts + */ +public fun generateHistogram2D( + samples: List, + nBinsX: Int, + nBinsY: Int, + xDomain: ClosedRange, + yDomain: ClosedRange, + xGetter: (T) -> X, + yGetter: (T) -> Y, +): HeatMapGrid where X : Comparable, X : Number, Y : Comparable, Y : Number { + require(nBinsX > 0 && nBinsY > 0) { "Number of bins must be positive." } + + val xRange = xDomain.endInclusive.toFloat() - xDomain.start.toFloat() + val yRange = yDomain.endInclusive.toFloat() - yDomain.start.toFloat() + + val bins = Array(nBinsX) { Array(nBinsY) { 0 } } + + for (sample in samples) { + val x = xGetter(sample).toFloat() + val y = yGetter(sample).toFloat() + + val bx = (((x - xDomain.start.toFloat()) / xRange) * nBinsX).toInt() + val by = (((y - yDomain.start.toFloat()) / yRange) * nBinsY).toInt() + + if (bx !in 0 until nBinsX) continue + if (by !in 0 until nBinsY) continue + + bins[bx][by]++ + } + + return bins +} From b4b0b2bda130eb7dbab35688049fe8e8b068d4d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Garc=C3=ADa=20Garz=C3=B3n?= Date: Tue, 9 Dec 2025 18:21:05 +0100 Subject: [PATCH 4/9] Fix Histogram2D with TDD, needed floor --- .../koalaplot/core/heatmap/Histogram2D.kt | 14 +- .../koalaplot/core/heatmap/Histogram2DTest.kt | 365 ++++++++++++++++++ 2 files changed, 372 insertions(+), 7 deletions(-) create mode 100644 src/desktopTest/kotlin/io/github/koalaplot/core/heatmap/Histogram2DTest.kt diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt index 8e544af5d..2e916f802 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt @@ -1,5 +1,7 @@ package io.github.koalaplot.core.heatmap +import kotlin.math.floor + /** * Generates a 2D histogram from a list of samples. * @@ -33,19 +35,17 @@ public fun generateHistogram2D( val yRange = yDomain.endInclusive.toFloat() - yDomain.start.toFloat() val bins = Array(nBinsX) { Array(nBinsY) { 0 } } - for (sample in samples) { val x = xGetter(sample).toFloat() val y = yGetter(sample).toFloat() - val bx = (((x - xDomain.start.toFloat()) / xRange) * nBinsX).toInt() - val by = (((y - yDomain.start.toFloat()) / yRange) * nBinsY).toInt() + val ix = floor(nBinsX * (x - xDomain.start.toFloat()) / xRange).toInt() + val iy = floor(nBinsY * (y - yDomain.start.toFloat()) / yRange).toInt() - if (bx !in 0 until nBinsX) continue - if (by !in 0 until nBinsY) continue + if (ix !in 0 until nBinsX) continue + if (iy !in 0 until nBinsY) continue - bins[bx][by]++ + bins[ix][iy]++ } - return bins } diff --git a/src/desktopTest/kotlin/io/github/koalaplot/core/heatmap/Histogram2DTest.kt b/src/desktopTest/kotlin/io/github/koalaplot/core/heatmap/Histogram2DTest.kt new file mode 100644 index 000000000..a818ec94e --- /dev/null +++ b/src/desktopTest/kotlin/io/github/koalaplot/core/heatmap/Histogram2DTest.kt @@ -0,0 +1,365 @@ +@file:Suppress("MagicNumber") + +package io.github.koalaplot.core.heatmap + +import org.junit.Test +import kotlin.test.assertEquals + +/** Test helper class for 2D points */ +data class TestPoint2D( + val x: Double, + val y: Double, +) + +/** Lazy version of assertEquals */ +inline fun assertEquals( + expected: T, + actual: T, + lazyMessage: () -> String, +) { + kotlin.test.assertEquals(expected, actual, lazyMessage()) +} + +/** + * Creates and asserts histogram results with sensible defaults. + * Provides comprehensive error messages showing complete expected vs actual histogram. + * + * @param samples List of test points + * @param nBinsX Number of bins in X direction (default: 3) + * @param nBinsY Number of bins in Y direction (default: 3) + * @param xDomain X coordinate range (default: 0.0..3.0) + * @param yDomain Y coordinate range (default: 0.0..3.0) + * @param expected Expected 2D histogram array + * @param message Optional assertion message describing the test scenario + */ +private fun assertHistogram2D( + samples: List>, + nBinsX: Int = 2, + nBinsY: Int = 3, + xDomain: ClosedRange = 0.0..2.0, + yDomain: ClosedRange = 0.0..3.0, + expected: Array>, + message: String? = null, +) { + val result = generateHistogram2D( + samples = samples, + nBinsX = nBinsX, + nBinsY = nBinsY, + xDomain = xDomain, + yDomain = yDomain, + xGetter = { (x, y) -> x }, + yGetter = { (x, y) -> y }, + ) + + fun snapshot(message: String): String = buildString { + if (message != null) { + append("$message\n") + append("\n") + } + append("Expected histogram:\n") + append(formatHistogram(expected)) + append("\nActual histogram:\n") + append(formatHistogram(result)) + append("\n") + append("Input samples: ${samples.map { (x,y) -> "($x, $y)" }}\n") + append("Domain: X=$xDomain, Y=$yDomain, Bins: ${nBinsX}x${nBinsY}\n") + } + + assertEquals(expected.size, result.size) { + snapshot("${message ?: ""}X bins count mismatch. Expected: ${expected.size}, Actual: ${result.size}") + } + assertEquals(expected[0].size, result[0].size) { + snapshot("${message ?: ""}Y bins count mismatch. Expected: ${expected[0].size}, Actual: ${result[0].size}") + } + + // Compare all values with detailed error message showing complete histograms + for (y in 0 until nBinsY) { + for (x in 0 until nBinsX) { + val expectedValue = expected[x][y] + val actualValue = result[x][y] + assertEquals(expectedValue, actualValue) { + snapshot("Histogram mismatch at bin [$x,$y]: expected $expectedValue, got $actualValue") + } + } + } +} + +/** + * Formats histogram as readable grid for error messages. + * Displays the histogram as rows (Y) x columns (X) to match test data structure. + */ +private fun formatHistogram(histogram: Array>): String = buildString { + // Display as rows (Y dimension) with columns (X dimension) + for (x in histogram.indices) { + for (y in histogram[x].indices) { + append("${histogram[x][y].toString().padStart(2)} ") + } + append("\n") + } +} + +class Histogram2DTest { + @Test + fun `Histogram single sample at origin`() { + assertHistogram2D( + samples = listOf(0.0 to 0.0), + expected = arrayOf( + arrayOf(1, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample in middle of 0 0 bin`() { + assertHistogram2D( + samples = listOf(0.5 to 0.5), + expected = arrayOf( + arrayOf(1, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample on next x bin`() { + assertHistogram2D( + samples = listOf(1.0 to 0.5), + expected = arrayOf( + arrayOf(0, 0, 0), + arrayOf(1, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample just before next x bin`() { + assertHistogram2D( + samples = listOf(0.99 to 0.5), + expected = arrayOf( + arrayOf(1, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample beyond max x`() { + assertHistogram2D( + samples = listOf(2.0 to 0.5), + expected = arrayOf( + arrayOf(0, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample beyond min x`() { + assertHistogram2D( + samples = listOf(-0.1 to 0.5), + expected = arrayOf( + arrayOf(0, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample on next y bin`() { + assertHistogram2D( + samples = listOf(0.5 to 1.0), + expected = arrayOf( + arrayOf(0, 1, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample just before next y bin`() { + assertHistogram2D( + samples = listOf(0.5 to 0.99), + expected = arrayOf( + arrayOf(1, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample beyond max y`() { + assertHistogram2D( + samples = listOf(0.5 to 3.0), + expected = arrayOf( + arrayOf(0, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample beyond min y`() { + assertHistogram2D( + samples = listOf(0.5 to -0.1), + expected = arrayOf( + arrayOf(0, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample scaled x domain under threshold`() { + assertHistogram2D( + xDomain = 0.0..20.0, // x10 + samples = listOf(9.9 to 0.5), + expected = arrayOf( + arrayOf(1, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample scaled x domain over threshold`() { + assertHistogram2D( + xDomain = 0.0..20.0, // x10 + samples = listOf(10.0 to 0.5), + expected = arrayOf( + arrayOf(0, 0, 0), + arrayOf(1, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample scaled y domain under threshold`() { + assertHistogram2D( + yDomain = 0.0..30.0, // x10 + samples = listOf(0.5 to 9.9), + expected = arrayOf( + arrayOf(1, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample scaled y domain over threshold`() { + assertHistogram2D( + yDomain = 0.0..30.0, // x10 + samples = listOf(0.5 to 10.0), + expected = arrayOf( + arrayOf(0, 1, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample offsetted x domain under threshold`() { + assertHistogram2D( + xDomain = 10.0..12.0, // +10 + samples = listOf(10.9 to 0.5), + expected = arrayOf( + arrayOf(1, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample offsetted x domain over threshold`() { + assertHistogram2D( + xDomain = 10.0..12.0, // +10 + samples = listOf(11.0 to 0.5), + expected = arrayOf( + arrayOf(0, 0, 0), + arrayOf(1, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample offsetted y domain under threshold`() { + assertHistogram2D( + yDomain = 10.0..13.0, // +10 + samples = listOf(0.5 to 10.9), + expected = arrayOf( + arrayOf(1, 0, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram single sample offsetted y domain over threshold`() { + assertHistogram2D( + yDomain = 10.0..13.0, // +10 + samples = listOf(0.5 to 11.0), + expected = arrayOf( + arrayOf(0, 1, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram multiple samples in different bins get counted`() { + assertHistogram2D( + samples = listOf( + 0.5 to 1.5, + 1.5 to 2.5, + ), + expected = arrayOf( + arrayOf(0, 1, 0), + arrayOf(0, 0, 1), + ), + ) + } + + @Test + fun `Histogram multiple samples in same bin get added`() { + assertHistogram2D( + samples = listOf( + 0.5 to 1.5, + 0.7 to 1.2, + ), + expected = arrayOf( + arrayOf(0, 2, 0), + arrayOf(0, 0, 0), + ), + ) + } + + @Test + fun `Histogram with inverted x domains`() { + assertHistogram2D( + xDomain = 2.0..0.0, + samples = listOf( + 1.5 to 0.5, + 0.5 to 2.5, + ), + expected = arrayOf( + arrayOf(1, 0, 0), + arrayOf(0, 0, 1), + ), + ) + } + + @Test + fun `Histogram with inverted y domains`() { + assertHistogram2D( + yDomain = 3.0..0.0, + samples = listOf( + 1.5 to 0.5, + 0.5 to 2.5, + ), + expected = arrayOf( + arrayOf(1, 0, 0), + arrayOf(0, 0, 1), + ), + ) + } +} From 89581822f52b7ad1699f475ceeee6ab85f35ee42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Garc=C3=ADa=20Garz=C3=B3n?= Date: Wed, 10 Dec 2025 14:01:37 +0100 Subject: [PATCH 5/9] Fix: y axis was inverted --- .../io/github/koalaplot/core/heatmap/HeatMapPlot.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt index e68a50f1d..8ac821936 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt @@ -18,6 +18,7 @@ import io.github.koalaplot.core.animation.StartAnimationUseCase import io.github.koalaplot.core.style.KoalaPlotTheme import io.github.koalaplot.core.xygraph.Point import io.github.koalaplot.core.xygraph.XYGraphScope +import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -61,10 +62,10 @@ public fun , Y : Comparable, Z> XYGraphScope.HeatMapP // Pre-calculate cell size val cellWidth = (right - left) / xBins - val cellHeight = (bottom - top) / yBins + val cellHeight = (top - bottom) / yBins val cellSize = Size( - beta.value * cellWidth, - beta.value * cellHeight, + beta.value * abs(cellWidth), + beta.value * abs(cellHeight), ) val animationOffset = (1f - beta.value) / 2f @@ -77,7 +78,7 @@ public fun , Y : Comparable, Z> XYGraphScope.HeatMapP val cellColor = colorScale(value) val cellLeft = left + (xi + animationOffset) * cellWidth - val cellTop = top + (yi + animationOffset) * cellHeight + val cellTop = bottom + (yi + 1 + animationOffset) * cellHeight drawRect( color = cellColor, From cf3b2f757e64cf21c773140bb26d2293e02f8543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Garc=C3=ADa=20Garz=C3=B3n?= Date: Tue, 16 Dec 2025 19:58:23 +0100 Subject: [PATCH 6/9] Abstracted lerp and normalize for Ranges This generic implemantation centralizes domain mapping logic. --- .../koalaplot/core/heatmap/ColorScales.kt | 26 +-- .../koalaplot/core/heatmap/Histogram2D.kt | 11 +- .../io/github/koalaplot/core/util/Range.kt | 43 +++++ .../github/koalaplot/core/util/RangeTest.kt | 155 ++++++++++++++++++ 4 files changed, 208 insertions(+), 27 deletions(-) create mode 100644 src/commonMain/kotlin/io/github/koalaplot/core/util/Range.kt create mode 100644 src/desktopTest/kotlin/io/github/koalaplot/core/util/RangeTest.kt diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt index eb86ecb18..a785a5f74 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt @@ -2,6 +2,8 @@ package io.github.koalaplot.core.heatmap import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp +import io.github.koalaplot.core.util.normalize +import io.github.koalaplot.core.util.lerp public typealias ColorScale = (Z) -> Color @@ -14,10 +16,7 @@ public fun linearColorScale( domain: ClosedRange, colors: List, ): ColorScale where Z : Comparable, Z : Number = { value -> - val normalized = ( - (value.toFloat() - domain.start.toFloat()) / - (domain.endInclusive.toFloat() - domain.start.toFloat()) - ).coerceIn(0f, 1f) + val normalized = domain.normalize(value).toFloat().coerceIn(0f, 1f) if (colors.size == 1) { colors[0] @@ -43,10 +42,7 @@ public fun divergingColorScale( midColor: Color = Color.White, highColor: Color = Color.Red, ): ColorScale where Z : Comparable, Z : Number = { value -> - val normalized = ( - (value.toFloat() - domain.start.toFloat()) / - (domain.endInclusive.toFloat() - domain.start.toFloat()) - ).coerceIn(0f, 1f) + val normalized = domain.normalize(value).toFloat().coerceIn(0f, 1f) if (normalized < 0.5f) { val progress = normalized * 2f @@ -93,19 +89,9 @@ public fun discreteColorScale( require(colors.size >= 1) { "Scale needs at least one color" } val binCount = colors.size - val startFloat = domain.start.toFloat() - val endFloat = domain.endInclusive.toFloat() - val binSize = (endFloat - startFloat) / binCount val thresholds = (1 until binCount).map { i -> - when (domain.start) { - is Int -> (startFloat + i * binSize).toInt() as Z - is Float -> (startFloat + i * binSize) as Z - is Double -> (startFloat + i * binSize) as Z - is Long -> (startFloat + i * binSize).toLong() as Z - is Short -> (startFloat + i * binSize).toInt().toShort() as Z - is Byte -> (startFloat + i * binSize).toInt().toByte() as Z - else -> throw UnsupportedOperationException("Unsupported numeric type: ${domain.start::class}") - } + val normalized = (0..binCount).normalize(i) + domain.lerp(normalized) } return discreteColorScale( diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt index 2e916f802..c162c6ebb 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt @@ -1,5 +1,7 @@ package io.github.koalaplot.core.heatmap +import io.github.koalaplot.core.util.normalize +import io.github.koalaplot.core.util.lerp import kotlin.math.floor /** @@ -31,16 +33,11 @@ public fun generateHistogram2D( ): HeatMapGrid where X : Comparable, X : Number, Y : Comparable, Y : Number { require(nBinsX > 0 && nBinsY > 0) { "Number of bins must be positive." } - val xRange = xDomain.endInclusive.toFloat() - xDomain.start.toFloat() - val yRange = yDomain.endInclusive.toFloat() - yDomain.start.toFloat() - val bins = Array(nBinsX) { Array(nBinsY) { 0 } } for (sample in samples) { - val x = xGetter(sample).toFloat() - val y = yGetter(sample).toFloat() - val ix = floor(nBinsX * (x - xDomain.start.toFloat()) / xRange).toInt() - val iy = floor(nBinsY * (y - yDomain.start.toFloat()) / yRange).toInt() + val ix = (0..nBinsX).lerp(xDomain.normalize(xGetter(sample))) + val iy = (0..nBinsY).lerp(yDomain.normalize(yGetter(sample))) if (ix !in 0 until nBinsX) continue if (iy !in 0 until nBinsY) continue diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/util/Range.kt b/src/commonMain/kotlin/io/github/koalaplot/core/util/Range.kt new file mode 100644 index 000000000..2376af112 --- /dev/null +++ b/src/commonMain/kotlin/io/github/koalaplot/core/util/Range.kt @@ -0,0 +1,43 @@ +package io.github.koalaplot.core.util + +@Suppress("UNCHECKED_CAST") +private fun doubleToTypeOf(value: Double, example: Z): Z = + when (example) { + is Double -> value as Z + is Float -> value.toFloat() as Z + is Int -> kotlin.math.floor(value).toInt() as Z + is Long -> kotlin.math.floor(value).toLong() as Z + is Short -> kotlin.math.floor(value).toInt().toShort() as Z + is Byte -> kotlin.math.floor(value).toInt().toByte() as Z + else -> throw UnsupportedOperationException("Unsupported numeric type: ${example::class}") + } + +/** + * Linearly normalizes the value within the range between 0.0 and 1.0. + * Values outside the range are extrapolated. + * When extremes of the range are equal, always returns zero. + * This is the inverse of operation [lerp]. + */ +public fun ClosedRange.normalize(value: T): Double + where T : Number, T : Comparable +{ + val r0 = start.toDouble() + val r1 = endInclusive.toDouble() + val size = r1 - r0 + if (size == 0.0) return 0.0 + return (value.toDouble() - r0) / size +} + +/** + * Linearly interpolates within the range by the factor t. + * For t values beyond 0.0..1.0, linear extrapolation is done. + * This is the inverse of operation [normalize]. + */ +public fun ClosedRange.lerp(t: Double): T + where T : Number, T : Comparable +{ + val r0 = start.toDouble() + val r1 = endInclusive.toDouble() + val size = r1 - r0 + return doubleToTypeOf(t * size + r0, start) +} diff --git a/src/desktopTest/kotlin/io/github/koalaplot/core/util/RangeTest.kt b/src/desktopTest/kotlin/io/github/koalaplot/core/util/RangeTest.kt new file mode 100644 index 000000000..32fbca36e --- /dev/null +++ b/src/desktopTest/kotlin/io/github/koalaplot/core/util/RangeTest.kt @@ -0,0 +1,155 @@ +package io.github.koalaplot.core.util + +import org.junit.Test +import kotlin.test.assertEquals + +class RangeTest { + + internal fun assertLerpEquals( + range: ClosedRange, + expected: List>, + ) where T: Comparable, T: Number { + val result = expected.map { it.first to range.lerp(it.first) } + assertEquals(expected.toString(), result.toString()) + } + + @Test + fun testLerpDoubleScales() { + assertLerpEquals( + range = 0.0..10.0, + expected = listOf( + +0.0 to 0.0, + +1.0 to 10.0, + +0.5 to 5.0, + -1.0 to -10.0, + +1.5 to 15.0, + ), + ) + } + + @Test + fun testLerpDoubleOffsets() { + assertLerpEquals( + range = 10.0..11.0, + expected = listOf( + +0.0 to 10.0, + +1.0 to 11.0, + +0.5 to 10.5, + -1.0 to 9.0, + +1.5 to 11.5, + ), + ) + } + + @Test + fun testLerpFloatScales() { + assertLerpEquals( + range = 0.0f..10.0f, + expected = listOf( + +0.0 to 0.0f, + +1.0 to 10.0f, + +0.5 to 5.0f, + -1.0 to -10.0f, + +1.5 to 15.0f, + ), + ) + } + + @Test + fun testLerpFloatOffsets() { + assertLerpEquals( + range = 10.0f..11.0f, + expected = listOf( + +0.0 to 10.0f, + +1.0 to 11.0f, + +0.5 to 10.5f, + -1.0 to 9.0f, + +1.5 to 11.5f, + ), + ) + } + + @Test + fun testLerpIntegerDoesFloor() { + assertLerpEquals( + range = 0..10, + expected = listOf( + +0.0 to 0, + +0.99 to 9, + +1.0 to 10, + +0.19 to 1, + +0.2 to 2, + // negative does floor + -0.2 to -2, + -0.199 to -2, + -0.21 to -3, + ), + ) + } + + @Test + fun testLerpIntegerInverted() { + assertLerpEquals( + range = 10..0, + expected = listOf( + +0.0 to 10, + +1.0 to 0, + +0.99 to 0, + +1.1 to -1, + +0.19 to 8, + +0.2 to 8, + +0.21 to 7, + -0.21 to 12, + -0.2 to 12, + -0.199 to 11, + ), + ) + } + + internal fun assertNormalizeEquals( + range: ClosedRange, + expected: List>, + ) where T: Comparable, T: Number { + val result = expected.map { it.first to range.normalize(it.first) } + assertEquals(expected.toString(), result.toString()) + } + + @Test + fun testNormalizeInteger() { + assertNormalizeEquals( + range = 0..10, + expected = listOf( + 0 to 0.0, + 10 to 1.0, + 2 to 0.2, + -1 to -0.1, + 11 to 1.1, + ), + ) + } + + @Test + fun testNormalizeDouble() { + assertNormalizeEquals( + range = -10.0..+10.0, + expected = listOf( + -10.0 to 0.0, + +10.0 to 1.0, + +0.0 to 0.5, + +2.0 to 0.6, + ), + ) + } + + fun testNormalizeDoubleInverted() { + assertNormalizeEquals( + range = +10.0..-10.0, + expected = listOf( + -10.0 to 1.0, + +10.0 to 0.0, + +0.0 to 0.95, + +2.0 to 0.4, + ), + ) + } +} From dd0346fd7825194d9cdf7f0fb1a18ae99a394446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Garc=C3=ADa=20Garz=C3=B3n?= Date: Fri, 19 Dec 2025 05:25:24 +0100 Subject: [PATCH 7/9] HeatMapPlot: Pass linters Histogram2D: make 2 parameters optional to fullfill <6 mandatory arguments Supress Magic numbers Supress multiple continue ktlint Formatting --- .../koalaplot/core/heatmap/ColorScales.kt | 3 +- .../koalaplot/core/heatmap/HeatMapPlot.kt | 1 + .../koalaplot/core/heatmap/Histogram2D.kt | 8 +-- .../io/github/koalaplot/core/util/Range.kt | 52 ++++++++++++++----- .../github/koalaplot/core/util/RangeTest.kt | 5 +- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt index a785a5f74..8a10ed6e3 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/ColorScales.kt @@ -2,8 +2,8 @@ package io.github.koalaplot.core.heatmap import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.lerp -import io.github.koalaplot.core.util.normalize import io.github.koalaplot.core.util.lerp +import io.github.koalaplot.core.util.normalize public typealias ColorScale = (Z) -> Color @@ -36,6 +36,7 @@ public fun linearColorScale( * @param midColor Color for midpoint values * @param highColor Color for high values */ +@Suppress("MagicNumber") public fun divergingColorScale( domain: ClosedRange, lowColor: Color = Color.Blue, diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt index 8ac821936..c109faaa6 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt @@ -70,6 +70,7 @@ public fun , Y : Comparable, Z> XYGraphScope.HeatMapP val animationOffset = (1f - beta.value) / 2f for (xi in 0 until xBins) { + @Suppress("LoopWithTooManyJumpStatements") for (yi in 0 until yBins) { val value = bins[xi][yi] ?: continue diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt index c162c6ebb..55092249a 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/Histogram2D.kt @@ -1,7 +1,7 @@ package io.github.koalaplot.core.heatmap -import io.github.koalaplot.core.util.normalize import io.github.koalaplot.core.util.lerp +import io.github.koalaplot.core.util.normalize import kotlin.math.floor /** @@ -22,20 +22,20 @@ import kotlin.math.floor * @param yGetter Function to extract y-coordinate from a sample * @return HeatMapGrid containing the histogram counts */ +@Suppress("LoopWithTooManyJumpStatements") public fun generateHistogram2D( samples: List, - nBinsX: Int, - nBinsY: Int, xDomain: ClosedRange, yDomain: ClosedRange, xGetter: (T) -> X, yGetter: (T) -> Y, + nBinsX: Int = 100, + nBinsY: Int = 100, ): HeatMapGrid where X : Comparable, X : Number, Y : Comparable, Y : Number { require(nBinsX > 0 && nBinsY > 0) { "Number of bins must be positive." } val bins = Array(nBinsX) { Array(nBinsY) { 0 } } for (sample in samples) { - val ix = (0..nBinsX).lerp(xDomain.normalize(xGetter(sample))) val iy = (0..nBinsY).lerp(yDomain.normalize(yGetter(sample))) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/util/Range.kt b/src/commonMain/kotlin/io/github/koalaplot/core/util/Range.kt index 2376af112..b55dfce68 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/util/Range.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/util/Range.kt @@ -1,17 +1,45 @@ package io.github.koalaplot.core.util @Suppress("UNCHECKED_CAST") -private fun doubleToTypeOf(value: Double, example: Z): Z = - when (example) { - is Double -> value as Z - is Float -> value.toFloat() as Z - is Int -> kotlin.math.floor(value).toInt() as Z - is Long -> kotlin.math.floor(value).toLong() as Z - is Short -> kotlin.math.floor(value).toInt().toShort() as Z - is Byte -> kotlin.math.floor(value).toInt().toByte() as Z - else -> throw UnsupportedOperationException("Unsupported numeric type: ${example::class}") +private fun doubleToTypeOf( + value: Double, + example: Z, +): Z = when (example) { + is Double -> { + value as Z } + is Float -> { + value.toFloat() as Z + } + + is Int -> { + kotlin.math.floor(value).toInt() as Z + } + + is Long -> { + kotlin.math.floor(value).toLong() as Z + } + + is Short -> { + kotlin.math + .floor(value) + .toInt() + .toShort() as Z + } + + is Byte -> { + kotlin.math + .floor(value) + .toInt() + .toByte() as Z + } + + else -> { + throw UnsupportedOperationException("Unsupported numeric type: ${example::class}") + } +} + /** * Linearly normalizes the value within the range between 0.0 and 1.0. * Values outside the range are extrapolated. @@ -19,8 +47,7 @@ private fun doubleToTypeOf(value: Double, example: Z): Z = * This is the inverse of operation [lerp]. */ public fun ClosedRange.normalize(value: T): Double - where T : Number, T : Comparable -{ + where T : Number, T : Comparable { val r0 = start.toDouble() val r1 = endInclusive.toDouble() val size = r1 - r0 @@ -34,8 +61,7 @@ public fun ClosedRange.normalize(value: T): Double * This is the inverse of operation [normalize]. */ public fun ClosedRange.lerp(t: Double): T - where T : Number, T : Comparable -{ + where T : Number, T : Comparable { val r0 = start.toDouble() val r1 = endInclusive.toDouble() val size = r1 - r0 diff --git a/src/desktopTest/kotlin/io/github/koalaplot/core/util/RangeTest.kt b/src/desktopTest/kotlin/io/github/koalaplot/core/util/RangeTest.kt index 32fbca36e..839a958b4 100644 --- a/src/desktopTest/kotlin/io/github/koalaplot/core/util/RangeTest.kt +++ b/src/desktopTest/kotlin/io/github/koalaplot/core/util/RangeTest.kt @@ -4,11 +4,10 @@ import org.junit.Test import kotlin.test.assertEquals class RangeTest { - internal fun assertLerpEquals( range: ClosedRange, expected: List>, - ) where T: Comparable, T: Number { + ) where T : Comparable, T : Number { val result = expected.map { it.first to range.lerp(it.first) } assertEquals(expected.toString(), result.toString()) } @@ -109,7 +108,7 @@ class RangeTest { internal fun assertNormalizeEquals( range: ClosedRange, expected: List>, - ) where T: Comparable, T: Number { + ) where T : Comparable, T : Number { val result = expected.map { it.first to range.normalize(it.first) } assertEquals(expected.toString(), result.toString()) } From 3e5aec999d072766df197c6ced6fae92328fc34f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Garc=C3=ADa=20Garz=C3=B3n?= Date: Fri, 26 Dec 2025 03:31:38 +0100 Subject: [PATCH 8/9] HeatMapPlot Javadoc --- .../io/github/koalaplot/core/heatmap/HeatMapPlot.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt index c109faaa6..d62683c9c 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt @@ -24,6 +24,16 @@ import kotlin.math.min public typealias HeatMapGrid = Array> +/** + * A HeatMap plot displays 2-dimensional data values as color. + * + * @param xDomain Domain for the x dimension. + * @param yDomain Domain for the y dimension. + * @param bins An 2D array of values. + * @param colorScale A mapping function from value to color. + * @param alphaScale A mapping function from value to alpha. + * @param animationSpec The [AnimationSpec] to use for animating the plot. + */ @Composable public fun , Y : Comparable, Z> XYGraphScope.HeatMapPlot( xDomain: ClosedRange, From e5521ac72f4a7d7706ae5bed006a7e8a0b63b8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Garc=C3=ADa=20Garz=C3=B3n?= Date: Fri, 26 Dec 2025 03:38:08 +0100 Subject: [PATCH 9/9] HeatMapPlot: extract inner loop as function It avoids multiple continue linter error by turning them into returns in the extracted function. --- .../koalaplot/core/heatmap/HeatMapPlot.kt | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt index d62683c9c..a23286f50 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/heatmap/HeatMapPlot.kt @@ -79,24 +79,28 @@ public fun , Y : Comparable, Z> XYGraphScope.HeatMapP ) val animationOffset = (1f - beta.value) / 2f - for (xi in 0 until xBins) { - @Suppress("LoopWithTooManyJumpStatements") - for (yi in 0 until yBins) { - val value = bins[xi][yi] ?: continue - - val alpha = alphaScale(value) * beta.value - if (alpha <= 0f) continue + fun drawRect( + xi: Int, + yi: Int, + ) { + val value = bins[xi][yi] ?: return + val alpha = alphaScale(value) * beta.value + if (alpha <= 0f) return + val cellColor = colorScale(value) + val cellLeft = left + (xi + animationOffset) * cellWidth + val cellTop = bottom + (yi + 1 + animationOffset) * cellHeight - val cellColor = colorScale(value) - val cellLeft = left + (xi + animationOffset) * cellWidth - val cellTop = bottom + (yi + 1 + animationOffset) * cellHeight + drawRect( + color = cellColor, + topLeft = Offset(cellLeft, cellTop), + size = cellSize, + alpha = alpha, + ) + } - drawRect( - color = cellColor, - topLeft = Offset(cellLeft, cellTop), - size = cellSize, - alpha = alpha, - ) + for (xi in 0 until xBins) { + for (yi in 0 until yBins) { + drawRect(xi, yi) } } }