From e0ff070e02d5a1d00dc5ab2cae1bd519d67d7dca Mon Sep 17 00:00:00 2001 From: Peter Getek Date: Tue, 16 Sep 2025 11:46:52 +0200 Subject: [PATCH 1/7] feature: adding 2 shape implementations for bar charts - first shape starts with flat and ends with convex shaped side - second shape starts and ends with convex shaped side - intermediary stacked bars start with concave shaped side potentially breaking changes: - using standardized Composable function typealias for emitting vertical bar since it contains fundamental data required for sophisticated bar rendering - introducing default Composable function typealias using `VerticalBarPlotEntry` type parameter - applying `roundToInt` only to range expression; removing from height calculation, since rounding results in up to 2.47% of error client side adjustments due to breaking changes: - expressions like `bar = { DefaultVerticalBar(SolidColor(Color.Blue)) }` must be changed to `bar = { _,_,_ -> DefaultVerticalBar(SolidColor(Color.Blue)) }` - use of factory functions is uneffected --- .../koalaplot/core/bar/BarPlotShapes.kt | 327 ++++++++++++++++++ .../core/bar/GroupedVerticalBarPlot.kt | 30 +- .../core/bar/StackedVerticalBarPlot.kt | 44 +-- .../koalaplot/core/bar/VerticalBarPlot.kt | 76 +++- 4 files changed, 430 insertions(+), 47 deletions(-) create mode 100644 src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt new file mode 100644 index 000000000..b0ffd8c82 --- /dev/null +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt @@ -0,0 +1,327 @@ +package io.github.koalaplot.core.bar + +import androidx.compose.runtime.Stable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Matrix +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import io.github.koalaplot.core.util.rad +import io.github.koalaplot.core.util.toDegrees +import io.github.koalaplot.core.xygraph.AxisModel +import io.github.koalaplot.core.xygraph.XYGraphScope +import kotlin.math.asin +import kotlin.math.max + +/** + * Rectangle shape with convex shaped side. + * Useful for Single Vertical Bar Plot rendering. + * Use in Stacked Bars is discouraged. + */ +@Stable +private val PlaneConvexShape: Shape = object : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val shapeWidth = size.width + val shapeHeight = size.height + val arcRadius = shapeWidth / 2 + + return Path().apply { + val rectHeight = max((shapeHeight - arcRadius), 0F) + addRect( + rect = Rect( + offset = Offset(0F, arcRadius), + size = Size(shapeWidth, rectHeight) + ) + ) + + val heightRadiusOffset = max((arcRadius - shapeHeight), 0F) + val heightRadiusOffsetDegrees = + asin(heightRadiusOffset / arcRadius).rad.toDegrees().value.toFloat() + addArc( + oval = Size(shapeWidth, shapeWidth).toRect(), + startAngleDegrees = 180F + heightRadiusOffsetDegrees, + sweepAngleDegrees = 180F - 2 * heightRadiusOffsetDegrees + ) + }.let(Outline::Generic) + } +} + +/** + * Rectangle shape with convex shaped sides. + * Useful for Single Vertical Bar Plot rendering. + * Use in Stacked Bars is discouraged. + */ +@Stable +private val BiConvexShape: Shape = object : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val outline = PlaneConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + + val shapeWidth = size.width + val shapeHeight = size.height + val arcRadius = shapeWidth / 2 + + val cutoutRect = Path().apply { + addRect( + rect = Rect( + offset = Offset(0F, shapeHeight - arcRadius), + size = Size(shapeWidth, shapeWidth) + ) + ) + } + val cutoutArc = Path().apply { + addArc( + oval = Rect( + offset = Offset(0F, shapeHeight - shapeWidth), + size = Size(shapeWidth, shapeWidth) + ), + startAngleDegrees = 0F, + sweepAngleDegrees = 180F + ) + } + val cutout = (cutoutRect - cutoutArc) + return (outline.path - cutout).let(Outline::Generic) + } +} + +/** + * Rectangle shape with concave/convex shaped sides. + * Useful for Single Vertical Bar and Stacked Bars Plot rendering. + * + * @param xyGraphScope Provides access to [yAxisModel] and acts as an implementation of [XYGraphScope]. + * @param value The [VerticalBarPlotEntry] that defines the cutouts for the [ConcaveConvexShape]. + */ +@Stable +public class ConcaveConvexShape>( + private val xyGraphScope: XYGraphScope, + private val index: Int, + private val value: E +) : Shape, XYGraphScope by xyGraphScope { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val shapeWidth = size.width + val shapeHeight = size.height + val arcRadius = shapeWidth / 2 + + // Rendering negative values + val isInverted = value.y.yMax < value.y.yMin + // Required for proper bar rendering in waterfall charts + if (index == 0) { + val outline = PlaneConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + + outline.path.apply { + // Rendering bar in negative direction + if (isInverted) { + inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } + } + return outline + } + + val (yZeroOffset, yMinOffset, yMaxOffset) = yAxisModel.yOffsets(value.y.yMin, value.y.yMax) + + // Prevent division by zero + return if (yMaxOffset == yMinOffset) { + Path().let(Outline::Generic) + } else { + val heightOffsetRatio = size.height / (yMaxOffset - yMinOffset) + val offsetToHeight = { offset: Float -> offset * heightOffsetRatio } + + val yMinZeroOffset = yMinOffset - yZeroOffset + val yMaxZeroOffset = yMaxOffset - yZeroOffset + + val yMinZeroHeight = offsetToHeight(yMinZeroOffset) + val yMaxZeroHeight = offsetToHeight(yMaxZeroOffset) + + val yMinZeroArcHeight = max((arcRadius - yMinZeroHeight), 0F) + val yMaxZeroArcHeight = max((arcRadius - yMaxZeroHeight), 0F) + + val yMaxZeroArcHeightDegrees = + asin(yMaxZeroArcHeight / arcRadius).rad.toDegrees().value.toFloat() + + Path().apply { + ( + Path().apply { + addArc( + oval = Size(shapeWidth, shapeWidth).toRect(), + startAngleDegrees = 180F + yMaxZeroArcHeightDegrees, + sweepAngleDegrees = 180F - 2 * yMaxZeroArcHeightDegrees + ) + } - Path().apply { + addArc( + oval = Rect( + offset = Offset(0F, shapeHeight), + size = Size(shapeWidth, shapeWidth) + ), + startAngleDegrees = 180F, + sweepAngleDegrees = 180F + ) + } + ).let(::addPath) + + ( + Path().apply { + addRect( + rect = Rect( + offset = Offset(0F, arcRadius), + size = Size(shapeWidth, max(shapeHeight - yMinZeroArcHeight, 0F)) + ) + ) + } - Path().apply { + addArc( + oval = Rect( + offset = Offset(0F, shapeHeight), + size = Size(shapeWidth, shapeWidth) + ), + startAngleDegrees = 180F, + sweepAngleDegrees = 180F + ) + } + ).let(::addPath) + // Rendering bar in negative direction + if (isInverted) { + inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } + }.let(Outline::Generic) + } + } +} + +/** + * Rectangle shape with concave/convex shaped sides and additional convex cutout at the bottom. + * Useful for Single Vertical Bar and Stacked Bars Plot rendering. + * + * Primary constructor: + * @param concaveConvexShape The internal shape logic used for rendering. + * @param value The [VerticalBarPlotEntry] that defines the cutouts for the [ConcaveConvexShape]. + * + * Secondary constructor: + * @param xyGraphScope Provides access to [yAxisModel] and acts as an implementation of [XYGraphScope]. + * @param value The [VerticalBarPlotEntry] used to construct the internal shape as well as the additional convex cutout. + */ +@Stable +public class ConvexConcaveConvexShape> private constructor( + private val concaveConvexShape: ConcaveConvexShape, + private val index: Int, + private val value: E +) : Shape, XYGraphScope by concaveConvexShape { + + public constructor( + xyGraphScope: XYGraphScope, + index: Int, + value: E + ) : this( + ConcaveConvexShape(xyGraphScope, index, value), + index, + value + ) + + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val shapeWidth = size.width + val shapeHeight = size.height + val arcRadius = shapeWidth / 2 + + // Rendering negative values + val isInverted = value.y.yMax < value.y.yMin + // Required for proper bar rendering in waterfall charts + if (index == 0) { + val outline = BiConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + + outline.path.apply { + // Rendering bar in negative direction + if (isInverted) { + inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } + } + return outline + } + + val (yZeroOffset, yMinOffset, yMaxOffset) = yAxisModel.yOffsets(value.y.yMin, value.y.yMax) + + // Prevent division by zero + return if (yMaxOffset == yMinOffset) { + Path().let(Outline::Generic) + } else { + val heightOffsetRatio = size.height / (yMaxOffset - yMinOffset) + val offsetToHeight = { offset: Float -> offset * heightOffsetRatio } + + val yMaxZeroOffset = yMaxOffset - yZeroOffset + val yMaxZeroHeight = offsetToHeight(yMaxZeroOffset) + + val outline = concaveConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + + val cutoutRect = Path().apply { + addRect( + rect = Rect( + offset = Offset(0F, yMaxZeroHeight - arcRadius), + size = Size(shapeWidth, shapeWidth) + ) + ) + } + val cutoutArc = Path().apply { + addArc( + oval = Rect( + offset = Offset(0F, yMaxZeroHeight - shapeWidth), + size = Size(shapeWidth, shapeWidth) + ), + startAngleDegrees = 0F, + sweepAngleDegrees = 180F + ) + } + val cutout = (cutoutRect - cutoutArc).apply { + // Rendering bar in negative direction + if (isInverted) { + inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } + } + (outline.path - cutout).let(Outline::Generic) + } + } +} + +private fun Path.inverted(pivotX: Float, pivotY: Float): Path { + Matrix().apply { + resetToPivotedTransform( + pivotX = pivotX, + pivotY = pivotY, + rotationZ = 180F + ) + }.let(::transform) + return this +} + +private fun AxisModel.yOffsets(yMin: Float, yMax: Float): YOffsets { + val yZeroOffset = computeOffset(0F).coerceIn(0F, 1F) + val yMinOffset = computeOffset(yMin).coerceIn(0f, 1f) + val yMaxOffset = computeOffset(yMax).coerceIn(0f, 1f) + return YOffsets( + yZeroOffset = yZeroOffset, + yMinOffset = yMinOffset, + yMaxOffset = yMaxOffset + ) +} + +private data class YOffsets( + val yZeroOffset: Float, + val yMinOffset: Float, + val yMaxOffset: Float +) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt index 20a7b80f9..501a4d0d8 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt @@ -61,7 +61,7 @@ public data class DefaultVerticalBarPlotGroupedPointEntry( public fun > XYGraphScope.GroupedVerticalBarPlot( data: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(dataIndex: Int, groupIndex: Int, entry: E) -> Unit = { i, g, _ -> + bar: VerticalBarComposable = { i, g, _ -> val colors = remember(data) { generateHueColorPalette(data.maxOf { it.y.size }) } @@ -102,7 +102,7 @@ public fun > XYGraphScope public fun > XYGraphScope.GroupedVerticalBarPlot( data: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(dataIndex: Int, groupIndex: Int, entry: E) -> Unit = { i, g, _ -> + bar: VerticalBarComposable = { i, g, _ -> val colors = remember(data) { generateHueColorPalette(data.maxOf { it.y.size }) } @@ -159,10 +159,10 @@ public fun > XYGraphScope element.y.forEachIndexed { i, verticalBarPosition -> val barMin = ( yAxisModel.computeOffset(verticalBarPosition.yMin).coerceIn(0f, 1f) * constraints.maxHeight - ).roundToInt() + ) val barMax = ( yAxisModel.computeOffset(verticalBarPosition.yMax).coerceIn(0f, 1f) * constraints.maxHeight - ).roundToInt() + ) val height = abs(barMax - barMin) * beta.value @@ -170,7 +170,7 @@ public fun > XYGraphScope Constraints(minWidth = 0, maxWidth = scaledBarWidth).fixedHeight(height.roundToInt()) ) elementPlaceables.add(p) - elementYBarPositions.add(barMin..barMax) + elementYBarPositions.add(barMin.roundToInt()..barMax.roundToInt()) } } @@ -264,7 +264,7 @@ public fun XYGraphScope.GroupedVerticalBarPlot( data class EntryWithBars( override val x: X, - val yb: List, @Composable BarScope.() -> Unit>> + val yb: List, DefaultVerticalBarComposable>> ) : VerticalBarPlotGroupedPointEntry { override val y: List> = object : AbstractList>() { override val size: Int = yb.size @@ -298,8 +298,13 @@ public fun XYGraphScope.GroupedVerticalBarPlot( GroupedVerticalBarPlot( data, modifier, - { xIndex, seriesIndex, _ -> - data.data[xIndex].yb[seriesIndex].second.invoke(this) + { xIndex, seriesIndex, value -> + data.data[xIndex].yb[seriesIndex].second.invoke( + this, + xIndex, + seriesIndex, + GroupedEntryToEntryAdapter(value) + ) }, maxBarGroupWidth, startAnimationUseCase = startAnimationUseCase, @@ -345,15 +350,18 @@ public interface GroupedVerticalBarPlotScope { * bars in this series. */ public fun series( - defaultBar: @Composable BarScope.() -> Unit = solidBar(Color.Blue), + defaultBar: DefaultVerticalBarComposable = solidBar(Color.Blue), content: VerticalBarPlotScope.() -> Unit ) } private class GroupedVerticalBarPlotScopeImpl : GroupedVerticalBarPlotScope { val series: MutableList> = mutableListOf() - override fun series(defaultBar: @Composable BarScope.() -> Unit, content: VerticalBarPlotScope.() -> Unit) { - val scope = VerticalBarPlotScopeImpl(defaultBar) + override fun series( + defaultBar: DefaultVerticalBarComposable, + content: VerticalBarPlotScope.() -> Unit + ) { + val scope = VerticalBarPlotScopeImpl(defaultBar) series.add(scope) scope.content() } diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedVerticalBarPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedVerticalBarPlot.kt index e42118c40..db581c8a9 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedVerticalBarPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedVerticalBarPlot.kt @@ -47,7 +47,7 @@ public data class DefaultVerticalBarPlotStackedPointEntry( public fun > XYGraphScope.StackedVerticalBarPlot( data: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(xIndex: Int, barIndex: Int) -> Unit, + bar: DefaultVerticalBarComposable, barWidth: Float = 0.9f, animationSpec: AnimationSpec = KoalaPlotTheme.animationSpec ) { @@ -58,8 +58,8 @@ public fun > XYGraphScope VerticalBarPlot( layerData, modifier, - { index -> - bar(index, barIndex) + { series, index, value -> + bar(index, barIndex, value) }, barWidth, animationSpec @@ -106,17 +106,17 @@ public fun XYGraphScope.StackedVerticalBarPlot( modifier: Modifier = Modifier, barWidth: Float = 0.9f, animationSpec: AnimationSpec = KoalaPlotTheme.animationSpec, - content: StackedVerticalBarPlotScope.() -> Unit + content: StackedVerticalBarPlotScope.() -> Unit ) { val scope = remember(content) { - val scope = StackedVerticalBarPlotScopeImpl() + val scope = StackedVerticalBarPlotScopeImpl() scope.content() scope } data class EntryWithBars( override val x: X, - val yb: List Unit>> + val yb: List>> ) : VerticalBarPlotStackedPointEntry { override val yOrigin = 0f @@ -154,8 +154,8 @@ public fun XYGraphScope.StackedVerticalBarPlot( StackedVerticalBarPlot( data, modifier, - { xIndex, seriesIndex -> - data.data[xIndex].yb[seriesIndex].second.invoke(this) + { xIndex, seriesIndex, value -> + data.data[xIndex].yb[seriesIndex].second.invoke(this, xIndex, seriesIndex, value) }, barWidth, animationSpec @@ -165,24 +165,24 @@ public fun XYGraphScope.StackedVerticalBarPlot( /** * Receiver scope used by [StackedVerticalBarPlot]. */ -public interface StackedVerticalBarPlotScope { +public interface StackedVerticalBarPlotScope { /** * Starts a new series of bars to be plotted, with a [defaultBar] to use for rendering all * bars in this series. */ public fun series( - defaultBar: @Composable BarScope.() -> Unit = solidBar(Color.Blue), - content: StackedVerticalBarPlotSeriesScope.() -> Unit + defaultBar: DefaultVerticalBarComposable = solidBar(Color.Blue), + content: StackedVerticalBarPlotSeriesScope.() -> Unit ) } -private class StackedVerticalBarPlotScopeImpl : StackedVerticalBarPlotScope { - val series: MutableList> = mutableListOf() +private class StackedVerticalBarPlotScopeImpl : StackedVerticalBarPlotScope { + val series: MutableList> = mutableListOf() override fun series( - defaultBar: @Composable BarScope.() -> Unit, - content: StackedVerticalBarPlotSeriesScope.() -> Unit + defaultBar: DefaultVerticalBarComposable, + content: StackedVerticalBarPlotSeriesScope.() -> Unit ) { - val scope = StackedVerticalBarPlotSeriesScopeImpl(defaultBar) + val scope = StackedVerticalBarPlotSeriesScopeImpl(defaultBar) series.add(scope) scope.content() } @@ -191,21 +191,21 @@ private class StackedVerticalBarPlotScopeImpl : StackedVerticalBarPlotScope { +public interface StackedVerticalBarPlotSeriesScope { /** * Adds an item at the specified [x] axis coordinate, with a vertical extent [y], which will * be added to series elements at the same x-axis coordinate already added to the plot. * An optional [bar] can be provided to customize the Composable used to * generate the bar for this specific item. */ - public fun item(x: X, y: Float, bar: (@Composable BarScope.() -> Unit)? = null) + public fun item(x: X, y: Float, bar: (DefaultVerticalBarComposable)? = null) } -private class StackedVerticalBarPlotSeriesScopeImpl(val defaultBar: @Composable BarScope.() -> Unit) : - StackedVerticalBarPlotSeriesScope { - val data: MutableMap Unit>> = mutableMapOf() +private class StackedVerticalBarPlotSeriesScopeImpl(val defaultBar: DefaultVerticalBarComposable) : + StackedVerticalBarPlotSeriesScope { + val data: MutableMap>> = mutableMapOf() - override fun item(x: X, y: Float, bar: (@Composable BarScope.() -> Unit)?) { + override fun item(x: X, y: Float, bar: (DefaultVerticalBarComposable)?) { data[x] = Pair(y, bar ?: defaultBar) } } diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt index 39043db2f..7db468ee6 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt @@ -90,6 +90,14 @@ internal class BarScopeImpl(val hoverableElementAreaScope: HoverableElementAreaS */ public typealias VerticalBarComposable = @Composable BarScope.(series: Int, index: Int, value: E) -> Unit +/** + * Defines a Composable function used to emit a vertical bar for [VerticalBarPlotEntry] values. + * Delegates to [VerticalBarComposable] with [VerticalBarPlotEntry] as type parameter. + * @param X The type of the x-axis values + * @param Y The type of the y-axis values + */ +public typealias DefaultVerticalBarComposable = VerticalBarComposable> + /** * A VerticalBarPlot to be used in an XYGraph and that plots a single series of data points as vertical bars. * @@ -106,7 +114,7 @@ public fun XYGraphScope.VerticalBarPlot( xData: List, yData: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(index: Int) -> Unit, + bar: DefaultVerticalBarComposable, barWidth: Float = 0.9f, animationSpec: AnimationSpec = KoalaPlotTheme.animationSpec ) { @@ -137,7 +145,7 @@ public fun XYGraphScope.VerticalBarPlot( public fun > XYGraphScope.VerticalBarPlot( data: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(index: Int) -> Unit, + bar: DefaultVerticalBarComposable, barWidth: Float = 0.9f, animationSpec: AnimationSpec = KoalaPlotTheme.animationSpec ) { @@ -149,8 +157,8 @@ public fun > XYGraphScope.VerticalBar GroupedVerticalBarPlot( dataAdapter, modifier = modifier, - bar = { dataIndex, _, _ -> - bar(dataIndex) + bar = { series, index, value -> + bar(series, index, GroupedEntryToEntryAdapter(value)) }, maxBarGroupWidth = barWidth, animationSpec = animationSpec @@ -178,6 +186,15 @@ private class EntryToGroupedEntryAdapter(val entry: VerticalBarPlotEntry( + private val entry: VerticalBarPlotGroupedPointEntry +) : VerticalBarPlotEntry { + override val x: X + get() = entry.x + override val y: VerticalBarPosition + get() = entry.y.first() +} + /** * Creates a Vertical Bar Plot. * @@ -188,13 +205,13 @@ private class EntryToGroupedEntryAdapter(val entry: VerticalBarPlotEntry XYGraphScope.VerticalBarPlot( - defaultBar: @Composable BarScope.() -> Unit = solidBar(Color.Blue), + defaultBar: DefaultVerticalBarComposable = solidBar(Color.Blue), modifier: Modifier = Modifier, barWidth: Float = 0.9f, animationSpec: AnimationSpec = KoalaPlotTheme.animationSpec, content: VerticalBarPlotScope.() -> Unit ) { - val scope = remember(content, defaultBar) { VerticalBarPlotScopeImpl(defaultBar) } + val scope = remember(content, defaultBar) { VerticalBarPlotScopeImpl(defaultBar) } val data = remember(scope) { scope.content() scope.data.values.toList() @@ -203,8 +220,8 @@ public fun XYGraphScope.VerticalBarPlot( VerticalBarPlot( data.map { it.first }, modifier, - { - data[it].second.invoke(this) + { series, index, value -> + data[index].second.invoke(this, series, index, value) }, barWidth, animationSpec @@ -220,15 +237,15 @@ public interface VerticalBarPlotScope { * [yMin] to [yMax]. An optional [bar] can be provided to customize the Composable used to * generate the bar for this specific item. */ - public fun item(x: X, yMin: Y, yMax: Y, bar: (@Composable BarScope.() -> Unit)? = null) + public fun item(x: X, yMin: Y, yMax: Y, bar: (DefaultVerticalBarComposable)? = null) } -internal class VerticalBarPlotScopeImpl(private val defaultBar: @Composable BarScope.() -> Unit) : +internal class VerticalBarPlotScopeImpl(private val defaultBar: DefaultVerticalBarComposable) : VerticalBarPlotScope { - val data: MutableMap, @Composable BarScope.() -> Unit>> = + val data: MutableMap, DefaultVerticalBarComposable>> = mutableMapOf() - override fun item(x: X, yMin: Y, yMax: Y, bar: (@Composable BarScope.() -> Unit)?) { + override fun item(x: X, yMin: Y, yMax: Y, bar: (DefaultVerticalBarComposable)?) { data[x] = Pair(verticalBarPlotEntry(x, yMin, yMax), bar ?: defaultBar) } } @@ -273,10 +290,41 @@ public fun BarScope.DefaultVerticalBar( /** * Factory function to create a Composable that emits a solid colored bar. */ -public fun solidBar( +public fun solidBar( color: Color, shape: Shape = RectangleShape, border: BorderStroke? = null, -): @Composable BarScope.() -> Unit = { +): DefaultVerticalBarComposable = { _, _, _ -> DefaultVerticalBar(SolidColor(color), shape = shape, border = border) } + +/** + * Factory function to create a Composable that emits a solid colored bar. + * The endings of each bar consist of a concave and a convex shape. + */ +public fun XYGraphScope.concaveConvexBar( + color: Color, + border: BorderStroke? = null, +): DefaultVerticalBarComposable = { _, index, value -> + DefaultVerticalBar( + brush = SolidColor(color), + shape = ConcaveConvexShape(this@concaveConvexBar, index, value), + border = border + ) +} + +/** + * Factory function to create a Composable that emits a solid colored bar. + * The endings of each bar consist of a concave and a convex shape. + * There's an additional convex cutout at the bottom of the bar. + */ +public fun XYGraphScope.convexConcaveConvexBar( + color: Color, + border: BorderStroke? = null, +): DefaultVerticalBarComposable = { _, index, value -> + DefaultVerticalBar( + brush = SolidColor(color), + shape = ConvexConcaveConvexShape(this@convexConcaveConvexBar, index, value), + border = border + ) +} From c0d846cf415ed7648502bd42fb12fc2fa2298e27 Mon Sep 17 00:00:00 2001 From: Peter Getek Date: Thu, 16 Oct 2025 10:52:54 +0200 Subject: [PATCH 2/7] fixing empty lambda in VerticalBarChartTest.kt --- .../kotlin/io/github/koalaplot/core/bar/VerticalBarChartTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/desktopTest/kotlin/io/github/koalaplot/core/bar/VerticalBarChartTest.kt b/src/desktopTest/kotlin/io/github/koalaplot/core/bar/VerticalBarChartTest.kt index e5f0ac686..851d5963e 100644 --- a/src/desktopTest/kotlin/io/github/koalaplot/core/bar/VerticalBarChartTest.kt +++ b/src/desktopTest/kotlin/io/github/koalaplot/core/bar/VerticalBarChartTest.kt @@ -29,7 +29,7 @@ class VerticalBarChartTest { DefaultVerticalBarPosition(0f, 10f) ) ), - bar = {} + bar = { _, _, _ -> } ) } } From 8e323552e66c2580a281315e75c2272fdd84b600 Mon Sep 17 00:00:00 2001 From: Peter Getek Date: Mon, 20 Oct 2025 10:58:11 +0200 Subject: [PATCH 3/7] adding comments to below zero compensation calculations --- .../io/github/koalaplot/core/bar/BarPlotShapes.kt | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt index b0ffd8c82..a2d047ab5 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt @@ -139,18 +139,26 @@ public class ConcaveConvexShape>( return if (yMaxOffset == yMinOffset) { Path().let(Outline::Generic) } else { + // AxisModel's `computeOffset` method provides relative values between 0 and 1 + // Mapping offset values to pixel values val heightOffsetRatio = size.height / (yMaxOffset - yMinOffset) val offsetToHeight = { offset: Float -> offset * heightOffsetRatio } + // Bars start with a concave path which might go below zero; the respective shape must be cut appropriately + // Calculating aforementioned offset values for a bar's max and min value relative to the axis zero line val yMinZeroOffset = yMinOffset - yZeroOffset val yMaxZeroOffset = yMaxOffset - yZeroOffset + // Getting screen's height pixel values from offsets val yMinZeroHeight = offsetToHeight(yMinZeroOffset) val yMaxZeroHeight = offsetToHeight(yMaxZeroOffset) + // If min and max values are greater than arcRadius, aforementioned below zero compensation is not required + // and therefore becomes ineffective val yMinZeroArcHeight = max((arcRadius - yMinZeroHeight), 0F) val yMaxZeroArcHeight = max((arcRadius - yMaxZeroHeight), 0F) + // Prevent arc from being drawn below zero by subtracting value in degrees val yMaxZeroArcHeightDegrees = asin(yMaxZeroArcHeight / arcRadius).rad.toDegrees().value.toFloat() @@ -261,10 +269,15 @@ public class ConvexConcaveConvexShape> pri return if (yMaxOffset == yMinOffset) { Path().let(Outline::Generic) } else { + // AxisModel's `computeOffset` method provides relative values between 0 and 1 + // Mapping offset values to pixel values val heightOffsetRatio = size.height / (yMaxOffset - yMinOffset) val offsetToHeight = { offset: Float -> offset * heightOffsetRatio } + // Bars start with a concave path which might go below zero; the respective shape must be cut appropriately + // Calculating aforementioned offset values for a bar's max and min value relative to the axis zero line val yMaxZeroOffset = yMaxOffset - yZeroOffset + // Getting screen's height pixel values from offsets val yMaxZeroHeight = offsetToHeight(yMaxZeroOffset) val outline = concaveConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic From e1133b1aba0a2d547c70cd3640b85d513cc5f7ee Mon Sep 17 00:00:00 2001 From: Peter Getek Date: Mon, 20 Oct 2025 11:24:11 +0200 Subject: [PATCH 4/7] undoing `roundToInt` adjustments --- .../io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt index 501a4d0d8..f932fad3c 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt @@ -159,10 +159,10 @@ public fun > XYGraphScope element.y.forEachIndexed { i, verticalBarPosition -> val barMin = ( yAxisModel.computeOffset(verticalBarPosition.yMin).coerceIn(0f, 1f) * constraints.maxHeight - ) + ).roundToInt() val barMax = ( yAxisModel.computeOffset(verticalBarPosition.yMax).coerceIn(0f, 1f) * constraints.maxHeight - ) + ).roundToInt() val height = abs(barMax - barMin) * beta.value @@ -170,7 +170,7 @@ public fun > XYGraphScope Constraints(minWidth = 0, maxWidth = scaledBarWidth).fixedHeight(height.roundToInt()) ) elementPlaceables.add(p) - elementYBarPositions.add(barMin.roundToInt()..barMax.roundToInt()) + elementYBarPositions.add(barMin..barMax) } } From 60d800446f5f3ab5d08756e159630fce1c80b582 Mon Sep 17 00:00:00 2001 From: Peter Getek Date: Mon, 20 Oct 2025 12:05:06 +0200 Subject: [PATCH 5/7] adjusting names of shapes to adhere to established standard --- .../koalaplot/core/bar/BarPlotShapes.kt | 28 +++++++++---------- .../koalaplot/core/bar/VerticalBarPlot.kt | 8 +++--- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt index a2d047ab5..ef93e49a5 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt @@ -24,7 +24,7 @@ import kotlin.math.max * Use in Stacked Bars is discouraged. */ @Stable -private val PlaneConvexShape: Shape = object : Shape { +private val DefaultPlanoConvexShape: Shape = object : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, @@ -61,13 +61,13 @@ private val PlaneConvexShape: Shape = object : Shape { * Use in Stacked Bars is discouraged. */ @Stable -private val BiConvexShape: Shape = object : Shape { +private val DefaultBiConvexShape: Shape = object : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { - val outline = PlaneConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + val outline = DefaultPlanoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic val shapeWidth = size.width val shapeHeight = size.height @@ -101,10 +101,10 @@ private val BiConvexShape: Shape = object : Shape { * Useful for Single Vertical Bar and Stacked Bars Plot rendering. * * @param xyGraphScope Provides access to [yAxisModel] and acts as an implementation of [XYGraphScope]. - * @param value The [VerticalBarPlotEntry] that defines the cutouts for the [ConcaveConvexShape]. + * @param value The [VerticalBarPlotEntry] that defines the cutouts for the [PlanoConvexShape]. */ @Stable -public class ConcaveConvexShape>( +public class PlanoConvexShape>( private val xyGraphScope: XYGraphScope, private val index: Int, private val value: E @@ -122,7 +122,7 @@ public class ConcaveConvexShape>( val isInverted = value.y.yMax < value.y.yMin // Required for proper bar rendering in waterfall charts if (index == 0) { - val outline = PlaneConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + val outline = DefaultPlanoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic outline.path.apply { // Rendering bar in negative direction @@ -215,26 +215,26 @@ public class ConcaveConvexShape>( * Useful for Single Vertical Bar and Stacked Bars Plot rendering. * * Primary constructor: - * @param concaveConvexShape The internal shape logic used for rendering. - * @param value The [VerticalBarPlotEntry] that defines the cutouts for the [ConcaveConvexShape]. + * @param planoConvexShape The internal shape logic used for rendering. + * @param value The [VerticalBarPlotEntry] that defines the cutouts for the [PlanoConvexShape]. * * Secondary constructor: * @param xyGraphScope Provides access to [yAxisModel] and acts as an implementation of [XYGraphScope]. * @param value The [VerticalBarPlotEntry] used to construct the internal shape as well as the additional convex cutout. */ @Stable -public class ConvexConcaveConvexShape> private constructor( - private val concaveConvexShape: ConcaveConvexShape, +public class BiConvexShape> private constructor( + private val planoConvexShape: PlanoConvexShape, private val index: Int, private val value: E -) : Shape, XYGraphScope by concaveConvexShape { +) : Shape, XYGraphScope by planoConvexShape { public constructor( xyGraphScope: XYGraphScope, index: Int, value: E ) : this( - ConcaveConvexShape(xyGraphScope, index, value), + PlanoConvexShape(xyGraphScope, index, value), index, value ) @@ -252,7 +252,7 @@ public class ConvexConcaveConvexShape> pri val isInverted = value.y.yMax < value.y.yMin // Required for proper bar rendering in waterfall charts if (index == 0) { - val outline = BiConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + val outline = DefaultBiConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic outline.path.apply { // Rendering bar in negative direction @@ -280,7 +280,7 @@ public class ConvexConcaveConvexShape> pri // Getting screen's height pixel values from offsets val yMaxZeroHeight = offsetToHeight(yMaxZeroOffset) - val outline = concaveConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + val outline = planoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic val cutoutRect = Path().apply { addRect( diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt index 7db468ee6..22d48c418 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt @@ -302,13 +302,13 @@ public fun solidBar( * Factory function to create a Composable that emits a solid colored bar. * The endings of each bar consist of a concave and a convex shape. */ -public fun XYGraphScope.concaveConvexBar( +public fun XYGraphScope.planoConvexBar( color: Color, border: BorderStroke? = null, ): DefaultVerticalBarComposable = { _, index, value -> DefaultVerticalBar( brush = SolidColor(color), - shape = ConcaveConvexShape(this@concaveConvexBar, index, value), + shape = PlanoConvexShape(this@planoConvexBar, index, value), border = border ) } @@ -318,13 +318,13 @@ public fun XYGraphScope.concaveConvexBar( * The endings of each bar consist of a concave and a convex shape. * There's an additional convex cutout at the bottom of the bar. */ -public fun XYGraphScope.convexConcaveConvexBar( +public fun XYGraphScope.biConvexBar( color: Color, border: BorderStroke? = null, ): DefaultVerticalBarComposable = { _, index, value -> DefaultVerticalBar( brush = SolidColor(color), - shape = ConvexConcaveConvexShape(this@convexConcaveConvexBar, index, value), + shape = BiConvexShape(this@biConvexBar, index, value), border = border ) } From 1433396888baf4dc3a412688f8731c2c3285a224 Mon Sep 17 00:00:00 2001 From: Peter Getek Date: Tue, 21 Oct 2025 17:02:45 +0200 Subject: [PATCH 6/7] adjusting HorizontalBarPlot addition and VerticalBarPlot refactoring to be compatible with custom shape rendering adding shapes and factory functions for horizontal barcharts with concave/convex endings, equivalent to vertical barchart variants standardizing barchart shape and factory function names (vertical/horizontal distinction) --- .../io/github/koalaplot/core/bar/Bar.kt | 91 ++++- .../koalaplot/core/bar/BarPlotShapes.kt | 350 +++++++++++++++++- .../core/bar/GroupedHorizontalBarPlot.kt | 22 +- .../core/bar/GroupedVerticalBarPlot.kt | 22 +- .../koalaplot/core/bar/HorizontalBarPlot.kt | 41 +- .../core/bar/StackedHorizontalBarPlot.kt | 44 +-- .../core/bar/StackedVerticalBarPlot.kt | 44 +-- .../koalaplot/core/bar/VerticalBarPlot.kt | 41 +- 8 files changed, 556 insertions(+), 99 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/Bar.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/Bar.kt index 1564863da..b0be7e468 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/Bar.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/Bar.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.SolidColor +import io.github.koalaplot.core.xygraph.XYGraphScope /** * A default implementation of a bar for bar charts. @@ -54,10 +55,96 @@ public fun BarScope.DefaultBar( /** * Factory function to create a Composable that emits a solid colored bar. */ -public fun solidBar( +@Deprecated( + message = "Delegates to vertical solid bar. Use explicitly dedicated factory function.", + replaceWith = ReplaceWith("verticalSolidBar(color, shape, border)") +) +public fun solidBar( color: Color, shape: Shape = RectangleShape, border: BorderStroke? = null, -): @Composable BarScope.() -> Unit = { +): DefaultVerticalBarComposable = verticalSolidBar(color, shape, border) + +/** + * Factory function to create a Composable that emits a solid colored bar. + */ +public fun verticalSolidBar( + color: Color, + shape: Shape = RectangleShape, + border: BorderStroke? = null, +): DefaultVerticalBarComposable = { _, _, _ -> + DefaultBar(SolidColor(color), shape = shape, border = border) +} + +/** + * Factory function to create a Composable that emits a solid colored bar. + */ +public fun horizontalSolidBar( + color: Color, + shape: Shape = RectangleShape, + border: BorderStroke? = null, +): DefaultHorizontalBarComposable = { _, _, _ -> DefaultBar(SolidColor(color), shape = shape, border = border) } + +/** + * Factory function to create a Composable that emits a solid colored bar. + * The endings of each bar consist of a concave and a convex shape. + */ +public fun XYGraphScope.verticalPlanoConvexBar( + color: Color, + border: BorderStroke? = null, +): DefaultVerticalBarComposable = { _, index, value -> + DefaultBar( + brush = SolidColor(color), + shape = VerticalPlanoConvexShape(this@verticalPlanoConvexBar, index, value), + border = border + ) +} + +/** + * Factory function to create a Composable that emits a solid colored bar. + * The endings of each bar consist of a concave and a convex shape. + * There's an additional convex cutout at the bottom of the bar. + */ +public fun XYGraphScope.verticalBiConvexBar( + color: Color, + border: BorderStroke? = null, +): DefaultVerticalBarComposable = { _, index, value -> + DefaultBar( + brush = SolidColor(color), + shape = VerticalBiConvexShape(this@verticalBiConvexBar, index, value), + border = border + ) +} + +/** + * Factory function to create a Composable that emits a solid colored bar. + * The endings of each bar consist of a concave and a convex shape. + */ +public fun XYGraphScope.horizontalPlanoConvexBar( + color: Color, + border: BorderStroke? = null, +): DefaultHorizontalBarComposable = { _, index, value -> + DefaultBar( + brush = SolidColor(color), + shape = HorizontalPlanoConvexShape(this@horizontalPlanoConvexBar, index, value), + border = border + ) +} + +/** + * Factory function to create a Composable that emits a solid colored bar. + * The endings of each bar consist of a concave and a convex shape. + * There's an additional convex cutout at the bottom of the bar. + */ +public fun XYGraphScope.horizontalBiConvexBar( + color: Color, + border: BorderStroke? = null, +): DefaultHorizontalBarComposable = { _, index, value -> + DefaultBar( + brush = SolidColor(color), + shape = HorizontalBiConvexShape(this@horizontalBiConvexBar, index, value), + border = border + ) +} diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt index ef93e49a5..7a4a2e062 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt @@ -24,7 +24,7 @@ import kotlin.math.max * Use in Stacked Bars is discouraged. */ @Stable -private val DefaultPlanoConvexShape: Shape = object : Shape { +private val DefaultVerticalPlanoConvexShape: Shape = object : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, @@ -55,19 +55,54 @@ private val DefaultPlanoConvexShape: Shape = object : Shape { } } +/** + * Rectangle shape with convex shaped side. + * Useful for Single Horizontal Bar Plot rendering. + * Use in Stacked Bars is discouraged. + */ +@Stable +private val DefaultHorizontalPlanoConvexShape: Shape = object : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val shapeWidth = size.width + val shapeHeight = size.height + val arcRadius = shapeHeight / 2 + + return Path().apply { + val rectWidth = max((shapeWidth - arcRadius), 0F) + addRect(Size(rectWidth, shapeHeight).toRect()) + + val widthRadiusOffset = max((arcRadius - shapeWidth), 0F) + val widthRadiusOffsetDegrees = + asin(widthRadiusOffset / arcRadius).rad.toDegrees().value.toFloat() + addArc( + oval = Rect( + offset = Offset(rectWidth - arcRadius - widthRadiusOffset, 0F), + size = Size(shapeHeight, shapeHeight) + ), + startAngleDegrees = 270F + widthRadiusOffsetDegrees, + sweepAngleDegrees = 180F - 2 * widthRadiusOffsetDegrees + ) + }.let(Outline::Generic) + } +} + /** * Rectangle shape with convex shaped sides. * Useful for Single Vertical Bar Plot rendering. * Use in Stacked Bars is discouraged. */ @Stable -private val DefaultBiConvexShape: Shape = object : Shape { +private val DefaultVerticalBiConvexShape: Shape = object : Shape { override fun createOutline( size: Size, layoutDirection: LayoutDirection, density: Density ): Outline { - val outline = DefaultPlanoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + val outline = DefaultVerticalPlanoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic val shapeWidth = size.width val shapeHeight = size.height @@ -96,15 +131,54 @@ private val DefaultBiConvexShape: Shape = object : Shape { } } +/** + * Rectangle shape with convex shaped sides. + * Useful for Single Horizontal Bar Plot rendering. + * Use in Stacked Bars is discouraged. + */ +@Stable +private val DefaultHorizontalBiConvexShape: Shape = object : Shape { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val outline = DefaultHorizontalPlanoConvexShape + .createOutline(size, layoutDirection, density) as Outline.Generic + + val shapeHeight = size.height + val arcRadius = shapeHeight / 2 + + val cutoutRect = Path().apply { + addRect( + rect = Rect( + offset = Offset(-arcRadius, 0F), + size = Size(shapeHeight, shapeHeight) + ) + ) + } + val cutoutArc = Path().apply { + addArc( + oval = Size(shapeHeight, shapeHeight).toRect(), + startAngleDegrees = 90F, + sweepAngleDegrees = 180F + ) + } + val cutout = (cutoutRect - cutoutArc) + return (outline.path - cutout).let(Outline::Generic) + } +} + /** * Rectangle shape with concave/convex shaped sides. * Useful for Single Vertical Bar and Stacked Bars Plot rendering. * * @param xyGraphScope Provides access to [yAxisModel] and acts as an implementation of [XYGraphScope]. - * @param value The [VerticalBarPlotEntry] that defines the cutouts for the [PlanoConvexShape]. + * @param index Represents the element index within the series. + * @param value The [VerticalBarPlotEntry] that defines the cutouts for the [VerticalPlanoConvexShape]. */ @Stable -public class PlanoConvexShape>( +public class VerticalPlanoConvexShape>( private val xyGraphScope: XYGraphScope, private val index: Int, private val value: E @@ -119,10 +193,11 @@ public class PlanoConvexShape>( val arcRadius = shapeWidth / 2 // Rendering negative values - val isInverted = value.y.yMax < value.y.yMin + val isInverted = value.y.end < value.y.start // Required for proper bar rendering in waterfall charts if (index == 0) { - val outline = DefaultPlanoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + val outline = + DefaultVerticalPlanoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic outline.path.apply { // Rendering bar in negative direction @@ -133,7 +208,7 @@ public class PlanoConvexShape>( return outline } - val (yZeroOffset, yMinOffset, yMaxOffset) = yAxisModel.yOffsets(value.y.yMin, value.y.yMax) + val (yZeroOffset, yMinOffset, yMaxOffset) = yAxisModel.yOffsets(value.y.start, value.y.end) // Prevent division by zero return if (yMaxOffset == yMinOffset) { @@ -210,21 +285,144 @@ public class PlanoConvexShape>( } } +/** + * Rectangle shape with concave/convex shaped sides. + * Useful for Single Horizontal Bar and Stacked Bars Plot rendering. + * + * @param xyGraphScope Provides access to [yAxisModel] and acts as an implementation of [XYGraphScope]. + * @param index Represents the element index within the series. + * @param value The [HorizontalBarPlotEntry] that defines the cutouts for the [HorizontalPlanoConvexShape]. + */ +@Stable +public class HorizontalPlanoConvexShape>( + private val xyGraphScope: XYGraphScope, + private val index: Int, + private val value: E +) : Shape, XYGraphScope by xyGraphScope { + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val shapeWidth = size.width + val shapeHeight = size.height + val arcRadius = shapeHeight / 2 + + // Rendering negative values + val isInverted = value.x.end < value.x.start + // Required for proper bar rendering in waterfall charts + if (index == 0) { + val outline = + DefaultHorizontalPlanoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + + outline.path.apply { + // Rendering bar in negative direction + if (isInverted) { + inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } + } + return outline + } + + val (xZeroOffset, xMinOffset, xMaxOffset) = xAxisModel.xOffsets(value.x.start, value.x.end) + + // Prevent division by zero + return if (xMaxOffset == xMinOffset) { + Path().let(Outline::Generic) + } else { + // AxisModel's `computeOffset` method provides relative values between 0 and 1 + // Mapping offset values to pixel values + val widthOffsetRatio = size.width / (xMaxOffset - xMinOffset) + val offsetToWidth = { offset: Float -> offset * widthOffsetRatio } + + // Bars start with a concave path which might go below zero; the respective shape must be cut appropriately + // Calculating aforementioned offset values for a bar's max and min value relative to the axis zero line + val xMinZeroOffset = xMinOffset - xZeroOffset + val xMaxZeroOffset = xMaxOffset - xZeroOffset + + // Getting screen's width pixel values from offsets + val xMinZeroWidth = offsetToWidth(xMinZeroOffset) + val xMaxZeroWidth = offsetToWidth(xMaxZeroOffset) + + // If min and max values are greater than arcRadius, aforementioned below zero compensation is not required + // and therefore becomes ineffective + val xMinZeroArcWidth = max((arcRadius - xMinZeroWidth), 0F) + val xMaxZeroArcWidth = max((arcRadius - xMaxZeroWidth), 0F) + + // Prevent arc from being drawn below zero by subtracting value in degrees + val xMaxZeroArcWidthDegrees = + asin(xMaxZeroArcWidth / arcRadius).rad.toDegrees().value.toFloat() + + Path().apply { + ( + Path().apply { + val rectWidth = max((shapeWidth - arcRadius), 0F) + val widthRadiusOffset = max((arcRadius - shapeWidth), 0F) + addArc( + oval = Rect( + offset = Offset(rectWidth - arcRadius - widthRadiusOffset, 0F), + size = Size(shapeHeight, shapeHeight) + ), + startAngleDegrees = 270F + xMaxZeroArcWidthDegrees, + sweepAngleDegrees = 180F - 2 * xMaxZeroArcWidthDegrees + ) + } - Path().apply { + addArc( + oval = Rect( + offset = Offset(-shapeHeight, 0F), + size = Size(shapeHeight, shapeHeight) + ), + startAngleDegrees = 270F, + sweepAngleDegrees = 180F + ) + } + ).let(::addPath) + + ( + Path().apply { + addRect( + rect = Rect( + offset = Offset(-arcRadius + xMinZeroArcWidth, 0F), + size = Size(max(shapeWidth - xMinZeroArcWidth, 0F), shapeHeight) + ) + ) + } - Path().apply { + addArc( + oval = Rect( + offset = Offset(-shapeHeight, 0F), + size = Size(shapeHeight, shapeHeight) + ), + startAngleDegrees = 270F, + sweepAngleDegrees = 180F + ) + } + ).let(::addPath) + // Rendering bar in negative direction + if (isInverted) { + inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } + }.let(Outline::Generic) + } + } +} + /** * Rectangle shape with concave/convex shaped sides and additional convex cutout at the bottom. * Useful for Single Vertical Bar and Stacked Bars Plot rendering. * * Primary constructor: * @param planoConvexShape The internal shape logic used for rendering. - * @param value The [VerticalBarPlotEntry] that defines the cutouts for the [PlanoConvexShape]. + * @param index Represents the element index within the series. + * @param value The [VerticalBarPlotEntry] that defines the cutouts for the [VerticalPlanoConvexShape]. * * Secondary constructor: * @param xyGraphScope Provides access to [yAxisModel] and acts as an implementation of [XYGraphScope]. + * @param index Represents the element index within the series. * @param value The [VerticalBarPlotEntry] used to construct the internal shape as well as the additional convex cutout. */ @Stable -public class BiConvexShape> private constructor( - private val planoConvexShape: PlanoConvexShape, +public class VerticalBiConvexShape> private constructor( + private val planoConvexShape: VerticalPlanoConvexShape, private val index: Int, private val value: E ) : Shape, XYGraphScope by planoConvexShape { @@ -234,7 +432,7 @@ public class BiConvexShape> private constr index: Int, value: E ) : this( - PlanoConvexShape(xyGraphScope, index, value), + VerticalPlanoConvexShape(xyGraphScope, index, value), index, value ) @@ -249,10 +447,10 @@ public class BiConvexShape> private constr val arcRadius = shapeWidth / 2 // Rendering negative values - val isInverted = value.y.yMax < value.y.yMin + val isInverted = value.y.end < value.y.start // Required for proper bar rendering in waterfall charts if (index == 0) { - val outline = DefaultBiConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + val outline = DefaultVerticalBiConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic outline.path.apply { // Rendering bar in negative direction @@ -263,7 +461,7 @@ public class BiConvexShape> private constr return outline } - val (yZeroOffset, yMinOffset, yMaxOffset) = yAxisModel.yOffsets(value.y.yMin, value.y.yMax) + val (yZeroOffset, yMinOffset, yMaxOffset) = yAxisModel.yOffsets(value.y.start, value.y.end) // Prevent division by zero return if (yMaxOffset == yMinOffset) { @@ -311,6 +509,111 @@ public class BiConvexShape> private constr } } +/** + * Rectangle shape with concave/convex shaped sides and additional convex cutout at the bottom. + * Useful for Single Horizontal Bar and Stacked Bars Plot rendering. + * + * Primary constructor: + * @param planoConvexShape The internal shape logic used for rendering. + * @param index Represents the element index within the series. + * @param value The [HorizontalBarPlotEntry] that defines the cutouts for the [HorizontalPlanoConvexShape]. + * + * Secondary constructor: + * @param xyGraphScope Provides access to [yAxisModel] and acts as an implementation of [XYGraphScope]. + * @param index Represents the element index within the series. + * @param value The [HorizontalBarPlotEntry] used to construct the internal shape + * as well as the additional convex cutout. + */ +@Stable +public class HorizontalBiConvexShape> private constructor( + private val planoConvexShape: HorizontalPlanoConvexShape, + private val index: Int, + private val value: E +) : Shape, XYGraphScope by planoConvexShape { + + public constructor( + xyGraphScope: XYGraphScope, + index: Int, + value: E + ) : this( + HorizontalPlanoConvexShape(xyGraphScope, index, value), + index, + value + ) + + override fun createOutline( + size: Size, + layoutDirection: LayoutDirection, + density: Density + ): Outline { + val shapeWidth = size.width + val shapeHeight = size.height + val arcRadius = shapeHeight / 2 + + // Rendering negative values + val isInverted = value.x.end < value.x.start + // Required for proper bar rendering in waterfall charts + if (index == 0) { + val outline = + DefaultHorizontalBiConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + + outline.path.apply { + // Rendering bar in negative direction + if (isInverted) { + inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } + } + return outline + } + + val (xZeroOffset, xMinOffset, xMaxOffset) = xAxisModel.xOffsets(value.x.start, value.x.end) + + // Prevent division by zero + return if (xMaxOffset == xMinOffset) { + Path().let(Outline::Generic) + } else { + // AxisModel's `computeOffset` method provides relative values between 0 and 1 + // Mapping offset values to pixel values + val widthOffsetRatio = size.width / (xMaxOffset - xMinOffset) + val offsetToWidth = { offset: Float -> offset * widthOffsetRatio } + + // Bars start with a concave path which might go below zero; the respective shape must be cut appropriately + // Calculating aforementioned offset values for a bar's max and min value relative to the axis zero line + val xMinZeroOffset = xMinOffset - xZeroOffset + // Getting screen's width pixel values from offsets + val xMinZeroWidth = offsetToWidth(xMinZeroOffset) + + val outline = planoConvexShape.createOutline(size, layoutDirection, density) as Outline.Generic + + val cutoutRect = Path().apply { + addRect( + rect = Rect( + offset = Offset(-xMinZeroWidth, 0F), + size = Size(arcRadius, shapeHeight) + ) + ) + } + val cutoutArc = Path().apply { + addArc( + oval = Rect( + offset = Offset(-xMinZeroWidth, 0F), + size = Size(shapeHeight, shapeHeight) + ), + startAngleDegrees = 90F, + sweepAngleDegrees = 180F + ) + } + val cutout = (cutoutRect - cutoutArc).apply { + // Rendering bar in negative direction + if (isInverted) { + inverted(pivotX = shapeWidth / 2F, pivotY = shapeHeight / 2F) + } + } + (outline.path - cutout).let(Outline::Generic) + } + } +} + private fun Path.inverted(pivotX: Float, pivotY: Float): Path { Matrix().apply { resetToPivotedTransform( @@ -322,6 +625,17 @@ private fun Path.inverted(pivotX: Float, pivotY: Float): Path { return this } +private fun AxisModel.xOffsets(xMin: Float, xMax: Float): XOffsets { + val xZeroOffset = computeOffset(0F).coerceIn(0F, 1F) + val xMinOffset = computeOffset(xMin).coerceIn(0f, 1f) + val xMaxOffset = computeOffset(xMax).coerceIn(0f, 1f) + return XOffsets( + xZeroOffset = xZeroOffset, + xMinOffset = xMinOffset, + xMaxOffset = xMaxOffset + ) +} + private fun AxisModel.yOffsets(yMin: Float, yMax: Float): YOffsets { val yZeroOffset = computeOffset(0F).coerceIn(0F, 1F) val yMinOffset = computeOffset(yMin).coerceIn(0f, 1f) @@ -333,6 +647,12 @@ private fun AxisModel.yOffsets(yMin: Float, yMax: Float): YOffsets { ) } +private data class XOffsets( + val xZeroOffset: Float, + val xMinOffset: Float, + val xMaxOffset: Float +) + private data class YOffsets( val yZeroOffset: Float, val yMinOffset: Float, diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedHorizontalBarPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedHorizontalBarPlot.kt index 027db73ae..839bbd243 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedHorizontalBarPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedHorizontalBarPlot.kt @@ -30,7 +30,7 @@ import io.github.koalaplot.core.xygraph.XYGraphScope public fun > XYGraphScope.GroupedHorizontalBarPlot( data: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(dataIndex: Int, groupIndex: Int, entry: E) -> Unit = { i, g, _ -> + bar: HorizontalBarComposable = { i, g, _ -> val colors = remember(data) { generateHueColorPalette(data.maxOf { it.d.size }) } @@ -97,7 +97,7 @@ public fun XYGraphScope.GroupedHorizontalBarPlot( data class EntryWithBars( override val i: Y, - val xb: List, @Composable BarScope.() -> Unit>> + val xb: List, DefaultHorizontalBarComposable>> ) : BarPlotGroupedPointEntry { override val d: List> = object : AbstractList>() { override val size: Int = xb.size @@ -131,8 +131,13 @@ public fun XYGraphScope.GroupedHorizontalBarPlot( GroupedHorizontalBarPlot( data, modifier, - { xIndex, seriesIndex, _ -> - data.data[xIndex].xb[seriesIndex].second.invoke(this) + { xIndex, seriesIndex, value -> + data.data[xIndex].xb[seriesIndex].second.invoke( + this, + xIndex, + seriesIndex, + GroupedEntryToHorizontalEntryAdapter(value) + ) }, maxBarGroupWidth, startAnimationUseCase = startAnimationUseCase, @@ -180,15 +185,18 @@ public interface GroupedHorizontalBarPlotScope { * bars in this series. */ public fun series( - defaultBar: @Composable BarScope.() -> Unit = solidBar(Color.Blue), + defaultBar: DefaultHorizontalBarComposable = horizontalSolidBar(Color.Blue), content: HorizontalBarPlotScope.() -> Unit ) } private class GroupedHorizontalBarPlotScopeImpl : GroupedHorizontalBarPlotScope { val series: MutableList> = mutableListOf() - override fun series(defaultBar: @Composable BarScope.() -> Unit, content: HorizontalBarPlotScope.() -> Unit) { - val scope = HorizontalBarPlotScopeImpl(defaultBar) + override fun series( + defaultBar: DefaultHorizontalBarComposable, + content: HorizontalBarPlotScope.() -> Unit + ) { + val scope = HorizontalBarPlotScopeImpl(defaultBar) series.add(scope) scope.content() } diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt index 77313705b..d9c4b562d 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/GroupedVerticalBarPlot.kt @@ -30,7 +30,7 @@ import io.github.koalaplot.core.xygraph.XYGraphScope public fun > XYGraphScope.GroupedVerticalBarPlot( data: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(dataIndex: Int, groupIndex: Int, entry: E) -> Unit = { i, g, _ -> + bar: VerticalBarComposable = { i, g, _ -> val colors = remember(data) { generateHueColorPalette(data.maxOf { it.d.size }) } @@ -97,7 +97,7 @@ public fun XYGraphScope.GroupedVerticalBarPlot( data class EntryWithBars( override val i: X, - val yb: List, @Composable BarScope.() -> Unit>> + val yb: List, DefaultVerticalBarComposable>> ) : BarPlotGroupedPointEntry { override val d: List> = object : AbstractList>() { override val size: Int = yb.size @@ -131,8 +131,13 @@ public fun XYGraphScope.GroupedVerticalBarPlot( GroupedVerticalBarPlot( data, modifier, - { xIndex, seriesIndex, _ -> - data.data[xIndex].yb[seriesIndex].second.invoke(this) + { xIndex, seriesIndex, value -> + data.data[xIndex].yb[seriesIndex].second.invoke( + this, + xIndex, + seriesIndex, + GroupedEntryToVerticalEntryAdapter(value) + ) }, maxBarGroupWidth, startAnimationUseCase = startAnimationUseCase, @@ -177,15 +182,18 @@ public interface GroupedVerticalBarPlotScope { * bars in this series. */ public fun series( - defaultBar: @Composable BarScope.() -> Unit = solidBar(Color.Blue), + defaultBar: DefaultVerticalBarComposable = verticalSolidBar(Color.Blue), content: VerticalBarPlotScope.() -> Unit ) } private class GroupedVerticalBarPlotScopeImpl : GroupedVerticalBarPlotScope { val series: MutableList> = mutableListOf() - override fun series(defaultBar: @Composable BarScope.() -> Unit, content: VerticalBarPlotScope.() -> Unit) { - val scope = VerticalBarPlotScopeImpl(defaultBar) + override fun series( + defaultBar: DefaultVerticalBarComposable, + content: VerticalBarPlotScope.() -> Unit + ) { + val scope = VerticalBarPlotScopeImpl(defaultBar) series.add(scope) scope.content() } diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/HorizontalBarPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/HorizontalBarPlot.kt index aa7239be5..30e561323 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/HorizontalBarPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/HorizontalBarPlot.kt @@ -54,6 +54,14 @@ public fun horizontalBarPlotEntry(y: Y, xMin: X, xMax: X): HorizontalBarP */ public typealias HorizontalBarComposable = @Composable BarScope.(series: Int, index: Int, value: E) -> Unit +/** + * Defines a Composable function used to emit a horizontal bar for [HorizontalBarPlotEntry] values. + * Delegates to [HorizontalBarComposable] with [HorizontalBarPlotEntry] as type parameter. + * @param X The type of the x-axis values + * @param Y The type of the y-axis values + */ +public typealias DefaultHorizontalBarComposable = HorizontalBarComposable> + /** * A HorizontalBarPlot to be used in an XYGraph and that plots a single series of data points as horizontal bars. * @@ -70,7 +78,7 @@ public fun XYGraphScope.HorizontalBarPlot( xData: List, yData: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(index: Int) -> Unit, + bar: DefaultHorizontalBarComposable, barWidth: Float = 0.9f, startAnimationUseCase: StartAnimationUseCase = StartAnimationUseCase( @@ -106,7 +114,7 @@ public fun XYGraphScope.HorizontalBarPlot( public fun > XYGraphScope.HorizontalBarPlot( data: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(index: Int) -> Unit, + bar: DefaultHorizontalBarComposable, barWidth: Float = 0.9f, startAnimationUseCase: StartAnimationUseCase = StartAnimationUseCase( @@ -122,8 +130,8 @@ public fun > XYGraphScope.Horizonta GroupedHorizontalBarPlot( dataAdapter, modifier = modifier, - bar = { dataIndex, _, _ -> - bar(dataIndex) + bar = { series, index, value -> + bar(series, index, GroupedEntryToHorizontalEntryAdapter(value)) }, maxBarGroupWidth = barWidth, startAnimationUseCase = startAnimationUseCase @@ -151,6 +159,15 @@ private class HorizontalEntryToGroupedEntryAdapter(val entry: HorizontalBa } } +internal class GroupedEntryToHorizontalEntryAdapter( + private val entry: BarPlotGroupedPointEntry +) : HorizontalBarPlotEntry { + override val y: Y + get() = entry.i + override val x: BarPosition + get() = entry.d.first() +} + /** * Creates a Horizontal Bar Plot. * @@ -161,7 +178,7 @@ private class HorizontalEntryToGroupedEntryAdapter(val entry: HorizontalBa */ @Composable public fun XYGraphScope.HorizontalBarPlot( - defaultBar: @Composable BarScope.() -> Unit = solidBar(Color.Blue), + defaultBar: DefaultHorizontalBarComposable = horizontalSolidBar(Color.Blue), modifier: Modifier = Modifier, barWidth: Float = 0.9f, startAnimationUseCase: StartAnimationUseCase = @@ -172,7 +189,7 @@ public fun XYGraphScope.HorizontalBarPlot( ), content: HorizontalBarPlotScope.() -> Unit ) { - val scope = remember(content, defaultBar) { HorizontalBarPlotScopeImpl(defaultBar) } + val scope = remember(content, defaultBar) { HorizontalBarPlotScopeImpl(defaultBar) } val data = remember(scope) { scope.content() scope.data.values.toList() @@ -181,8 +198,8 @@ public fun XYGraphScope.HorizontalBarPlot( HorizontalBarPlot( data.map { it.first }, modifier, - { - data[it].second.invoke(this) + { series, index, value -> + data[index].second.invoke(this, series, index, value) }, barWidth, startAnimationUseCase @@ -198,15 +215,15 @@ public interface HorizontalBarPlotScope { * [xMin] to [xMax]. An optional [bar] can be provided to customize the Composable used to * generate the bar for this specific item. */ - public fun item(y: Y, xMin: X, xMax: X, bar: (@Composable BarScope.() -> Unit)? = null) + public fun item(y: Y, xMin: X, xMax: X, bar: (DefaultHorizontalBarComposable)? = null) } -internal class HorizontalBarPlotScopeImpl(private val defaultBar: @Composable BarScope.() -> Unit) : +internal class HorizontalBarPlotScopeImpl(private val defaultBar: DefaultHorizontalBarComposable) : HorizontalBarPlotScope { - val data: MutableMap, @Composable BarScope.() -> Unit>> = + val data: MutableMap, DefaultHorizontalBarComposable>> = mutableMapOf() - override fun item(y: Y, xMin: X, xMax: X, bar: (@Composable BarScope.() -> Unit)?) { + override fun item(y: Y, xMin: X, xMax: X, bar: DefaultHorizontalBarComposable?) { data[y] = Pair(horizontalBarPlotEntry(y, xMin, xMax), bar ?: defaultBar) } } diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedHorizontalBarPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedHorizontalBarPlot.kt index 1ef8574ba..6d9cfcffc 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedHorizontalBarPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedHorizontalBarPlot.kt @@ -47,7 +47,7 @@ public data class DefaultHorizontalBarPlotStackedPointEntry( public fun > XYGraphScope.StackedHorizontalBarPlot( data: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(yIndex: Int, barIndex: Int) -> Unit, + bar: DefaultHorizontalBarComposable, barWidth: Float = 0.9f, startAnimationUseCase: StartAnimationUseCase = StartAnimationUseCase( @@ -63,8 +63,8 @@ public fun > XYGraphScope - bar(index, barIndex) + { series, index, value -> + bar(index, barIndex, value) }, barWidth, startAnimationUseCase @@ -119,17 +119,17 @@ public fun XYGraphScope.StackedHorizontalBarPlot( /* chart animation */ KoalaPlotTheme.animationSpec, ), - content: StackedHorizontalBarPlotScope.() -> Unit + content: StackedHorizontalBarPlotScope.() -> Unit ) { val scope = remember(content) { - val scope = StackedHorizontalBarPlotScopeImpl() + val scope = StackedHorizontalBarPlotScopeImpl() scope.content() scope } data class EntryWithBars( override val y: Y, - val xb: List Unit>> + val xb: List>> ) : HorizontalBarPlotStackedPointEntry { override val xOrigin = 0f @@ -167,8 +167,8 @@ public fun XYGraphScope.StackedHorizontalBarPlot( StackedHorizontalBarPlot( data, modifier, - { yIndex, seriesIndex -> - data.data[yIndex].xb[seriesIndex].second.invoke(this) + { yIndex, seriesIndex, value -> + data.data[yIndex].xb[seriesIndex].second.invoke(this, yIndex, seriesIndex, value) }, barWidth, startAnimationUseCase @@ -178,24 +178,24 @@ public fun XYGraphScope.StackedHorizontalBarPlot( /** * Receiver scope used by [StackedHorizontalBarPlot]. */ -public interface StackedHorizontalBarPlotScope { +public interface StackedHorizontalBarPlotScope { /** * Starts a new series of bars to be plotted, with a [defaultBar] to use for rendering all * bars in this series. */ public fun series( - defaultBar: @Composable BarScope.() -> Unit = solidBar(Color.Blue), - content: StackedHorizontalBarPlotSeriesScope.() -> Unit + defaultBar: DefaultHorizontalBarComposable = horizontalSolidBar(Color.Blue), + content: StackedHorizontalBarPlotSeriesScope.() -> Unit ) } -private class StackedHorizontalBarPlotScopeImpl : StackedHorizontalBarPlotScope { - val series: MutableList> = mutableListOf() +private class StackedHorizontalBarPlotScopeImpl : StackedHorizontalBarPlotScope { + val series: MutableList> = mutableListOf() override fun series( - defaultBar: @Composable BarScope.() -> Unit, - content: StackedHorizontalBarPlotSeriesScope.() -> Unit + defaultBar: DefaultHorizontalBarComposable, + content: StackedHorizontalBarPlotSeriesScope.() -> Unit ) { - val scope = StackedHorizontalBarPlotSeriesScopeImpl(defaultBar) + val scope = StackedHorizontalBarPlotSeriesScopeImpl(defaultBar) series.add(scope) scope.content() } @@ -204,21 +204,21 @@ private class StackedHorizontalBarPlotScopeImpl : StackedHorizontalBarPlotSco /** * Scope item to allow adding items to a [StackedHorizontalBarPlot]. */ -public interface StackedHorizontalBarPlotSeriesScope { +public interface StackedHorizontalBarPlotSeriesScope { /** * Adds an item at the specified [y] axis coordinate, with a horizontal extent [x], which will * be added to series elements at the same y-axis coordinate already added to the plot. * An optional [bar] can be provided to customize the Composable used to * generate the bar for this specific item. */ - public fun item(x: Float, y: Y, bar: (@Composable BarScope.() -> Unit)? = null) + public fun item(x: Float, y: Y, bar: (DefaultHorizontalBarComposable)? = null) } -private class StackedHorizontalBarPlotSeriesScopeImpl(val defaultBar: @Composable BarScope.() -> Unit) : - StackedHorizontalBarPlotSeriesScope { - val data: MutableMap Unit>> = mutableMapOf() +private class StackedHorizontalBarPlotSeriesScopeImpl(val defaultBar: DefaultHorizontalBarComposable) : + StackedHorizontalBarPlotSeriesScope { + val data: MutableMap>> = mutableMapOf() - override fun item(x: Float, y: Y, bar: (@Composable BarScope.() -> Unit)?) { + override fun item(x: Float, y: Y, bar: (DefaultHorizontalBarComposable)?) { data[y] = Pair(x, bar ?: defaultBar) } } diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedVerticalBarPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedVerticalBarPlot.kt index faf1c859e..38bf7bc30 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedVerticalBarPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/StackedVerticalBarPlot.kt @@ -47,7 +47,7 @@ public data class DefaultVerticalBarPlotStackedPointEntry( public fun > XYGraphScope.StackedVerticalBarPlot( data: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(xIndex: Int, barIndex: Int) -> Unit, + bar: DefaultVerticalBarComposable, barWidth: Float = 0.9f, startAnimationUseCase: StartAnimationUseCase = StartAnimationUseCase( @@ -63,8 +63,8 @@ public fun > XYGraphScope VerticalBarPlot( layerData, modifier, - { index -> - bar(index, barIndex) + { series, index, value -> + bar(index, barIndex, value) }, barWidth, startAnimationUseCase @@ -119,17 +119,17 @@ public fun XYGraphScope.StackedVerticalBarPlot( /* chart animation */ KoalaPlotTheme.animationSpec, ), - content: StackedVerticalBarPlotScope.() -> Unit + content: StackedVerticalBarPlotScope.() -> Unit ) { val scope = remember(content) { - val scope = StackedVerticalBarPlotScopeImpl() + val scope = StackedVerticalBarPlotScopeImpl() scope.content() scope } data class EntryWithBars( override val x: X, - val yb: List Unit>> + val yb: List>> ) : VerticalBarPlotStackedPointEntry { override val yOrigin = 0f @@ -167,8 +167,8 @@ public fun XYGraphScope.StackedVerticalBarPlot( StackedVerticalBarPlot( data, modifier, - { xIndex, seriesIndex -> - data.data[xIndex].yb[seriesIndex].second.invoke(this) + { xIndex, seriesIndex, value -> + data.data[xIndex].yb[seriesIndex].second.invoke(this, xIndex, seriesIndex, value) }, barWidth, startAnimationUseCase @@ -178,24 +178,24 @@ public fun XYGraphScope.StackedVerticalBarPlot( /** * Receiver scope used by [StackedVerticalBarPlot]. */ -public interface StackedVerticalBarPlotScope { +public interface StackedVerticalBarPlotScope { /** * Starts a new series of bars to be plotted, with a [defaultBar] to use for rendering all * bars in this series. */ public fun series( - defaultBar: @Composable BarScope.() -> Unit = solidBar(Color.Blue), - content: StackedVerticalBarPlotSeriesScope.() -> Unit + defaultBar: DefaultVerticalBarComposable = verticalSolidBar(Color.Blue), + content: StackedVerticalBarPlotSeriesScope.() -> Unit ) } -private class StackedVerticalBarPlotScopeImpl : StackedVerticalBarPlotScope { - val series: MutableList> = mutableListOf() +private class StackedVerticalBarPlotScopeImpl : StackedVerticalBarPlotScope { + val series: MutableList> = mutableListOf() override fun series( - defaultBar: @Composable BarScope.() -> Unit, - content: StackedVerticalBarPlotSeriesScope.() -> Unit + defaultBar: DefaultVerticalBarComposable, + content: StackedVerticalBarPlotSeriesScope.() -> Unit ) { - val scope = StackedVerticalBarPlotSeriesScopeImpl(defaultBar) + val scope = StackedVerticalBarPlotSeriesScopeImpl(defaultBar) series.add(scope) scope.content() } @@ -204,21 +204,21 @@ private class StackedVerticalBarPlotScopeImpl : StackedVerticalBarPlotScope { +public interface StackedVerticalBarPlotSeriesScope { /** * Adds an item at the specified [x] axis coordinate, with a vertical extent [y], which will * be added to series elements at the same x-axis coordinate already added to the plot. * An optional [bar] can be provided to customize the Composable used to * generate the bar for this specific item. */ - public fun item(x: X, y: Float, bar: (@Composable BarScope.() -> Unit)? = null) + public fun item(x: X, y: Float, bar: (DefaultVerticalBarComposable)? = null) } -private class StackedVerticalBarPlotSeriesScopeImpl(val defaultBar: @Composable BarScope.() -> Unit) : - StackedVerticalBarPlotSeriesScope { - val data: MutableMap Unit>> = mutableMapOf() +private class StackedVerticalBarPlotSeriesScopeImpl(val defaultBar: DefaultVerticalBarComposable) : + StackedVerticalBarPlotSeriesScope { + val data: MutableMap>> = mutableMapOf() - override fun item(x: X, y: Float, bar: (@Composable BarScope.() -> Unit)?) { + override fun item(x: X, y: Float, bar: (DefaultVerticalBarComposable)?) { data[x] = Pair(y, bar ?: defaultBar) } } diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt index 2819c35ca..96b0a21c4 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/VerticalBarPlot.kt @@ -54,6 +54,14 @@ public fun verticalBarPlotEntry(x: X, yMin: Y, yMax: Y): VerticalBarPlotE */ public typealias VerticalBarComposable = @Composable BarScope.(series: Int, index: Int, value: E) -> Unit +/** + * Defines a Composable function used to emit a vertical bar for [VerticalBarPlotEntry] values. + * Delegates to [VerticalBarComposable] with [VerticalBarPlotEntry] as type parameter. + * @param X The type of the x-axis values + * @param Y The type of the y-axis values + */ +public typealias DefaultVerticalBarComposable = VerticalBarComposable> + /** * A VerticalBarPlot to be used in an XYGraph and that plots a single series of data points as vertical bars. * @@ -70,7 +78,7 @@ public fun XYGraphScope.VerticalBarPlot( xData: List, yData: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(index: Int) -> Unit, + bar: DefaultVerticalBarComposable, barWidth: Float = 0.9f, startAnimationUseCase: StartAnimationUseCase = StartAnimationUseCase( @@ -106,7 +114,7 @@ public fun XYGraphScope.VerticalBarPlot( public fun > XYGraphScope.VerticalBarPlot( data: List, modifier: Modifier = Modifier, - bar: @Composable BarScope.(index: Int) -> Unit, + bar: DefaultVerticalBarComposable, barWidth: Float = 0.9f, startAnimationUseCase: StartAnimationUseCase = StartAnimationUseCase( @@ -123,8 +131,8 @@ public fun > XYGraphScope.VerticalBar GroupedVerticalBarPlot( dataAdapter, modifier = modifier, - bar = { dataIndex, _, _ -> - bar(dataIndex) + bar = { series, index, value -> + bar(series, index, GroupedEntryToVerticalEntryAdapter(value)) }, maxBarGroupWidth = barWidth, startAnimationUseCase = startAnimationUseCase @@ -152,6 +160,15 @@ private class VerticalEntryToGroupedEntryAdapter(val entry: VerticalBarPlo } } +internal class GroupedEntryToVerticalEntryAdapter( + private val entry: BarPlotGroupedPointEntry +) : VerticalBarPlotEntry { + override val x: X + get() = entry.i + override val y: BarPosition + get() = entry.d.first() +} + /** * Creates a Vertical Bar Plot. * @@ -162,7 +179,7 @@ private class VerticalEntryToGroupedEntryAdapter(val entry: VerticalBarPlo */ @Composable public fun XYGraphScope.VerticalBarPlot( - defaultBar: @Composable BarScope.() -> Unit = solidBar(Color.Blue), + defaultBar: DefaultVerticalBarComposable = verticalSolidBar(Color.Blue), modifier: Modifier = Modifier, barWidth: Float = 0.9f, startAnimationUseCase: StartAnimationUseCase = @@ -173,7 +190,7 @@ public fun XYGraphScope.VerticalBarPlot( ), content: VerticalBarPlotScope.() -> Unit ) { - val scope = remember(content, defaultBar) { VerticalBarPlotScopeImpl(defaultBar) } + val scope = remember(content, defaultBar) { VerticalBarPlotScopeImpl(defaultBar) } val data = remember(scope) { scope.content() scope.data.values.toList() @@ -182,8 +199,8 @@ public fun XYGraphScope.VerticalBarPlot( VerticalBarPlot( data.map { it.first }, modifier, - { - data[it].second.invoke(this) + { series, index, value -> + data[index].second.invoke(this, series, index, value) }, barWidth, startAnimationUseCase @@ -199,15 +216,15 @@ public interface VerticalBarPlotScope { * [yMin] to [yMax]. An optional [bar] can be provided to customize the Composable used to * generate the bar for this specific item. */ - public fun item(x: X, yMin: Y, yMax: Y, bar: (@Composable BarScope.() -> Unit)? = null) + public fun item(x: X, yMin: Y, yMax: Y, bar: (DefaultVerticalBarComposable)? = null) } -internal class VerticalBarPlotScopeImpl(private val defaultBar: @Composable BarScope.() -> Unit) : +internal class VerticalBarPlotScopeImpl(private val defaultBar: DefaultVerticalBarComposable) : VerticalBarPlotScope { - val data: MutableMap, @Composable BarScope.() -> Unit>> = + val data: MutableMap, DefaultVerticalBarComposable>> = mutableMapOf() - override fun item(x: X, yMin: Y, yMax: Y, bar: (@Composable BarScope.() -> Unit)?) { + override fun item(x: X, yMin: Y, yMax: Y, bar: (DefaultVerticalBarComposable)?) { data[x] = Pair(verticalBarPlotEntry(x, yMin, yMax), bar ?: defaultBar) } } From 2e9786b47fdd9e38ef3953a2573c6b47068f8911 Mon Sep 17 00:00:00 2001 From: Peter Getek Date: Sun, 26 Oct 2025 16:44:03 +0100 Subject: [PATCH 7/7] adjusting comments to align with implementation details and function names --- src/commonMain/kotlin/io/github/koalaplot/core/bar/Bar.kt | 8 ++++---- .../kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/Bar.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/Bar.kt index b0be7e468..b92b4bbec 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/Bar.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/Bar.kt @@ -89,7 +89,7 @@ public fun horizontalSolidBar( /** * Factory function to create a Composable that emits a solid colored bar. - * The endings of each bar consist of a concave and a convex shape. + * Each bar features a planar starting side and a convex ending side. */ public fun XYGraphScope.verticalPlanoConvexBar( color: Color, @@ -104,7 +104,7 @@ public fun XYGraphScope.verticalPlanoConvexBar( /** * Factory function to create a Composable that emits a solid colored bar. - * The endings of each bar consist of a concave and a convex shape. + * Each bar features a convex shape on both its starting and ending sides. * There's an additional convex cutout at the bottom of the bar. */ public fun XYGraphScope.verticalBiConvexBar( @@ -120,7 +120,7 @@ public fun XYGraphScope.verticalBiConvexBar( /** * Factory function to create a Composable that emits a solid colored bar. - * The endings of each bar consist of a concave and a convex shape. + * Each bar features a planar shape at one end and a convex shape at the other. */ public fun XYGraphScope.horizontalPlanoConvexBar( color: Color, @@ -135,7 +135,7 @@ public fun XYGraphScope.horizontalPlanoConvexBar( /** * Factory function to create a Composable that emits a solid colored bar. - * The endings of each bar consist of a concave and a convex shape. + * Each bar features a convex shape on both its starting and ending sides. * There's an additional convex cutout at the bottom of the bar. */ public fun XYGraphScope.horizontalBiConvexBar( diff --git a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt index 7a4a2e062..794cfc95d 100644 --- a/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt +++ b/src/commonMain/kotlin/io/github/koalaplot/core/bar/BarPlotShapes.kt @@ -170,7 +170,7 @@ private val DefaultHorizontalBiConvexShape: Shape = object : Shape { } /** - * Rectangle shape with concave/convex shaped sides. + * Rectangle shape with planar/convex shaped sides. * Useful for Single Vertical Bar and Stacked Bars Plot rendering. * * @param xyGraphScope Provides access to [yAxisModel] and acts as an implementation of [XYGraphScope]. @@ -286,7 +286,7 @@ public class VerticalPlanoConvexShape>( } /** - * Rectangle shape with concave/convex shaped sides. + * Rectangle shape with planar/convex shaped sides. * Useful for Single Horizontal Bar and Stacked Bars Plot rendering. * * @param xyGraphScope Provides access to [yAxisModel] and acts as an implementation of [XYGraphScope]. @@ -407,7 +407,7 @@ public class HorizontalPlanoConvexShape> } /** - * Rectangle shape with concave/convex shaped sides and additional convex cutout at the bottom. + * Rectangle shape with convex shaped sides and an additional convex cutout at the bottom. * Useful for Single Vertical Bar and Stacked Bars Plot rendering. * * Primary constructor: @@ -510,7 +510,7 @@ public class VerticalBiConvexShape> privat } /** - * Rectangle shape with concave/convex shaped sides and additional convex cutout at the bottom. + * Rectangle shape with convex shaped sides and an additional convex cutout at the bottom. * Useful for Single Horizontal Bar and Stacked Bars Plot rendering. * * Primary constructor: