diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/ChartAnimation.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/ChartDisplayAnimation.kt similarity index 79% rename from charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/ChartAnimation.kt rename to charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/ChartDisplayAnimation.kt index 881f379..500b5ba 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/ChartAnimation.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/ChartDisplayAnimation.kt @@ -3,20 +3,20 @@ package com.netguru.multiplatform.charts import androidx.compose.animation.core.AnimationSpec import androidx.compose.animation.core.tween -sealed class ChartAnimation { - object Disabled : ChartAnimation() +sealed class ChartDisplayAnimation { + object Disabled : ChartDisplayAnimation() class Simple( val animationSpec: () -> AnimationSpec = { tween(DEFAULT_DURATION, DEFAULT_DELAY) }, - ) : ChartAnimation() + ) : ChartDisplayAnimation() class Sequenced( val animationSpec: (dataSeriesIndex: Int) -> AnimationSpec = { index -> tween(DEFAULT_DURATION, index * DEFAULT_DELAY) }, - ) : ChartAnimation() + ) : ChartDisplayAnimation() private companion object { const val DEFAULT_DURATION = 300 diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/Helpers.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/Helpers.kt index 2fa0efa..2defe19 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/Helpers.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/Helpers.kt @@ -1,41 +1,55 @@ package com.netguru.multiplatform.charts +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.layout.layout +import kotlin.math.PI +import kotlin.math.absoluteValue +import kotlin.math.ceil +import kotlin.math.floor import kotlin.math.pow import kotlin.math.roundToInt +import kotlin.math.sign internal fun Double.mapValueToDifferentRange( inMin: Double, inMax: Double, outMin: Double, outMax: Double, -) = (this - inMin) * (outMax - outMin) / (inMax - inMin) + outMin +) = + if (inMin == inMax) (outMax - outMin).div(2.0) else (this - inMin) * (outMax - outMin) / (inMax - inMin) + outMin fun Float.mapValueToDifferentRange( inMin: Float, inMax: Float, outMin: Float, outMax: Float, -) = (this - inMin) * (outMax - outMin) / (inMax - inMin) + outMin +) = + if (inMin == inMax) (outMax - outMin).div(2F) else (this - inMin) * (outMax - outMin) / (inMax - inMin) + outMin fun Long.mapValueToDifferentRange( inMin: Long, inMax: Long, outMin: Long, outMax: Long, -) = (this - inMin) * (outMax - outMin) / (inMax - inMin) + outMin +) = + if (inMin == inMax) (outMax - outMin).div(2) else (this - inMin) * (outMax - outMin) / (inMax - inMin) + outMin fun Long.mapValueToDifferentRange( inMin: Long, inMax: Long, outMin: Float, outMax: Float, -) = (this - inMin) * (outMax - outMin) / (inMax - inMin) + outMin +) = + if (inMin == inMax) (outMax - outMin).div(2F) else (this - inMin) * (outMax - outMin) / (inMax - inMin) + outMin fun Number.round(decimals: Int = 2): String { return when (this) { @@ -46,6 +60,7 @@ fun Number.round(decimals: Int = 2): String { } catch (e: IllegalArgumentException) { "-" } + else -> { this.toString() } @@ -53,13 +68,71 @@ fun Number.round(decimals: Int = 2): String { } @Composable -internal fun StartAnimation(animation: ChartAnimation, data: Any): Boolean { - var animationPlayed by remember(data) { - mutableStateOf(animation is ChartAnimation.Disabled) +fun getAnimationAlphas( + animation: ChartDisplayAnimation, + numberOfElementsToAnimate: Int, + uniqueDatasetKey: Any, +): List { + var animationPlayed by remember(uniqueDatasetKey) { + mutableStateOf(animation is ChartDisplayAnimation.Disabled) } - LaunchedEffect(Unit) { + LaunchedEffect(uniqueDatasetKey) { animationPlayed = true } - return animationPlayed + return when (animation) { + ChartDisplayAnimation.Disabled -> (1..numberOfElementsToAnimate).map { 1f } + is ChartDisplayAnimation.Simple -> (1..numberOfElementsToAnimate).map { + animateFloatAsState( + targetValue = if (animationPlayed) 1f else 0f, + animationSpec = if (animationPlayed) animation.animationSpec() else tween(durationMillis = 0), + ).value + } + + is ChartDisplayAnimation.Sequenced -> (1..numberOfElementsToAnimate).map { + animateFloatAsState( + targetValue = if (animationPlayed) 1f else 0f, + animationSpec = if (animationPlayed) animation.animationSpec(it) else tween(durationMillis = 0), + ).value + } + } +} + +fun Modifier.vertical() = + rotate(-90f) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.height, placeable.width) { + placeable.place( + x = -(placeable.width / 2 - placeable.height / 2), + y = -(placeable.height / 2 - placeable.width / 2) + ) + } + } + +fun Float.toRadians() = this * PI.toFloat() / 180f + +fun Float.roundToMultiplicationOf(multiplicand: Float, roundToCeiling: Boolean): Float { + if (this == 0f) { + return 0f + } + if (multiplicand <= 0) { + throw IllegalArgumentException("multiplicand must be positive!") + } + + fun Float.ceilOrFloor(ceil: Boolean): Float = if (ceil) { + ceil(this) + } else { + floor(this) + } + + val round = when (sign) { + -1f -> !roundToCeiling + 1f -> roundToCeiling + else -> throw IllegalStateException("If `this == 0f` this line should never be reached") + } + + val closest = (absoluteValue / multiplicand).ceilOrFloor(round) * multiplicand + + return sign * closest } diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/OverlayInformation.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/OverlayInformation.kt index 51ba82e..5ca02bf 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/OverlayInformation.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/OverlayInformation.kt @@ -4,50 +4,118 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.requiredWidth import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -private val TOUCH_OFFSET = 20.dp -private val OVERLAY_WIDTH = 200.dp - @Composable internal fun OverlayInformation( - positionX: Float, + positionX: Float?, containerSize: Size, - surfaceColor: Color, - touchOffset: Dp = TOUCH_OFFSET, - overlayWidth: Dp = OVERLAY_WIDTH, + backgroundColor: Color, + touchOffsetVertical: Dp, + touchOffsetHorizontal: Dp, + requiredOverlayWidth: Dp?, + overlayAlpha: Float, + pointsToAvoid: List = emptyList(), content: @Composable () -> Unit, ) { - if (positionX < 0) return + if (positionX == null || positionX < 0) { + return + } + + val density = LocalDensity.current + + var overlayHeight by remember { + mutableStateOf(0) + } + var overlayWidth by remember { + val pxs: Int + with(density) { + pxs = requiredOverlayWidth?.roundToPx() ?: 0 + } + mutableStateOf(pxs) + } + + + val putInfoOnTheLeft = positionX > (containerSize.width / 2) + val (offsetX, offsetY) = remember(pointsToAvoid, overlayHeight, overlayWidth, putInfoOnTheLeft) { + pointsToAvoid + .takeIf { it.isNotEmpty() } + ?.let { + val x: Dp + val y: Dp + with(density) { + x = if (putInfoOnTheLeft) { + val minX = it.minOf { it.x }.toDp() + minX - touchOffsetHorizontal - overlayWidth.toDp() + } else { + val maxX = it.maxOf { it.x }.toDp() + maxX + touchOffsetHorizontal + } + + val minY = it.minOf { it.y } + val maxY = it.maxOf { it.y } + y = (containerSize.height - ((maxY + minY) / 2) - (overlayHeight / 2)) + .coerceIn( + minimumValue = 0f, + maximumValue = containerSize.height - overlayHeight + ) + .toDp() + } + + x to y + } + ?: run { + with(density) { + positionX.toDp() + + // change offset based on cursor position to avoid out of screen drawing on the right + if (putInfoOnTheLeft) { + -overlayWidth.toDp() - touchOffsetHorizontal + } else { + touchOffsetHorizontal + } + } to touchOffsetVertical + } + } + BoxWithConstraints( modifier = Modifier + .onSizeChanged { + overlayHeight = it.height + if (requiredOverlayWidth == null) { + overlayWidth = it.width + } + } .offset( - x = with(LocalDensity.current) { - positionX.toDp() + - // change offset based on cursor position to avoid out of screen drawing on the right - if (positionX.toDp() > (containerSize.width / 2).toDp()) { - -OVERLAY_WIDTH - TOUCH_OFFSET - } else { - TOUCH_OFFSET - } - }, - y = touchOffset + x = offsetX, + y = offsetY, ) - .width(OVERLAY_WIDTH) - .alpha(0.9f) + .alpha(overlayAlpha) .clip(RoundedCornerShape(10.dp)) - .background(surfaceColor) + .background(backgroundColor) .padding(8.dp) + .then( + if (requiredOverlayWidth != null) { + Modifier.requiredWidth(requiredOverlayWidth) + } else { + Modifier + } + ) ) { content() } diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChart.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChart.kt index debd1f1..80e9841 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChart.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChart.kt @@ -1,6 +1,5 @@ package com.netguru.multiplatform.charts.bar -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -10,6 +9,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -22,15 +22,16 @@ import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp -import com.netguru.multiplatform.charts.ChartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation import com.netguru.multiplatform.charts.OverlayInformation -import com.netguru.multiplatform.charts.StartAnimation -import com.netguru.multiplatform.charts.grid.GridDefaults +import com.netguru.multiplatform.charts.getAnimationAlphas +import com.netguru.multiplatform.charts.grid.ChartGridDefaults +import com.netguru.multiplatform.charts.grid.YAxisTitleData import com.netguru.multiplatform.charts.grid.LineParameters import com.netguru.multiplatform.charts.grid.YAxisLabels import com.netguru.multiplatform.charts.grid.alignCenterToOffsetHorizontal -import com.netguru.multiplatform.charts.grid.axisscale.FixedTicksXAxisScale -import com.netguru.multiplatform.charts.grid.axisscale.YAxisScale +import com.netguru.multiplatform.charts.grid.axisscale.x.FixedTicksXAxisScale +import com.netguru.multiplatform.charts.grid.axisscale.y.YAxisScaleDynamic import com.netguru.multiplatform.charts.grid.drawChartGrid import com.netguru.multiplatform.charts.grid.measureChartGrid import com.netguru.multiplatform.charts.line.PointF @@ -44,9 +45,9 @@ import com.netguru.multiplatform.charts.theme.ChartTheme * @param colors The only parameter used is [ChartColors.grid]. Others play no role in * BarChart. Colors of the bars themselves are specified together with the data * @param config The parameters for chart appearance customization - * @param xAxisLabel Composable to mark the values on the x-axis. - * @param yAxisLabel Composable to mark the values on the y-axis. - * @param animation In the case of [ChartAnimation.Sequenced] items with the same index in each + * @param xAxisMarkerLayout Composable to mark the values on the x-axis. + * @param yAxisMarkerLayout Composable to mark the values on the y-axis. + * @param animation In the case of [ChartDisplayAnimation.Sequenced] items with the same index in each * category will animate together * values */ @@ -56,42 +57,37 @@ fun BarChart( modifier: Modifier = Modifier, colors: BarChartColors = ChartTheme.colors.barChartColors, config: BarChartConfig = BarChartConfig(), - xAxisLabel: @Composable (value: Any) -> Unit = GridDefaults.XAxisLabel, - yAxisLabel: @Composable (value: Any) -> Unit = GridDefaults.YAxisLabel, - animation: ChartAnimation = ChartAnimation.Simple(), - overlayDataEntryLabel: @Composable (dataName: String, value: Any) -> Unit = GridDefaults.OverlayDataEntryLabel, + xAxisMarkerLayout: @Composable (value: Any) -> Unit = ChartGridDefaults.XAxisMarkerLayout, + yAxisMarkerLayout: @Composable (value: Any) -> Unit = ChartGridDefaults.YAxisMarkerLayout, + yAxisLabelLayout: (@Composable () -> Unit)? = null, + animation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), + overlayDataEntryLabel: @Composable (dataName: String, dataNameShort: String?, dataUnit: String?, value: Any) -> Unit = ChartGridDefaults.TooltipDataEntryLabel, ) { val verticalLinesCount = remember(data) { data.maxX.toInt() + 1 } - val horizontalLinesOffset = - GridDefaults.HORIZONTAL_LINES_OFFSET // TODO check why y-axis-labels get the other way around with large values for offset - - val animationPlayed = StartAnimation(animation, data) var verticalGridLines by remember { mutableStateOf(emptyList()) } var horizontalGridLines by remember { mutableStateOf(emptyList()) } var chartBars by remember { mutableStateOf(emptyList()) } var selectedBar by remember { mutableStateOf?>(null) } - val valueScale = when (animation) { - ChartAnimation.Disabled -> data.categories.first().entries.indices.map { 1f } - is ChartAnimation.Simple -> data.categories.first().entries.indices.map { - animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = animation.animationSpec() - ).value - } - is ChartAnimation.Sequenced -> data.categories.first().entries.indices.map { - animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = animation.animationSpec(it) - ).value - } - } + val valueScale = getAnimationAlphas( + animation = animation, + numberOfElementsToAnimate = data.categories.first().entries.size, + uniqueDatasetKey = data, + ) Row(modifier = modifier) { YAxisLabels( horizontalGridLines = horizontalGridLines, - yAxisMarkerLayout = yAxisLabel, + yAxisMarkerLayout = yAxisMarkerLayout, + yAxisTitleData = yAxisLabelLayout?.let { + YAxisTitleData( + labelLayout = yAxisLabelLayout, + labelPosition = YAxisTitleData.LabelPosition.Left, + ) + }, + modifier = Modifier + .padding(end = 8.dp) ) Spacer(modifier = Modifier.size(width = 4.dp, height = 0.dp)) @@ -111,7 +107,7 @@ fun BarChart( event.changes.forEach { inputChange -> selectedBar = chartBars.firstOrNull { inputChange.position.x in it.width && - inputChange.position.y in it.height + inputChange.position.y in it.height }?.let { PointF( inputChange.position.x, @@ -123,11 +119,11 @@ fun BarChart( } } ) { - val yAxisScale = YAxisScale( - min = data.minY, - max = data.maxY, - maxTickCount = config.maxHorizontalLinesCount, - roundClosestTo = config.roundMinMaxClosestTo, + val yAxisScale = YAxisScaleDynamic( + chartData = data, + maxNumberOfHorizontalLines = config.maxHorizontalLinesCount, + roundMarkersToMultiplicationOf = config.roundMinMaxClosestTo, + forceShowingValueZeroLine = true, ) val grid = measureChartGrid( xAxisScale = FixedTicksXAxisScale( @@ -136,7 +132,6 @@ fun BarChart( tickCount = verticalLinesCount - 1 ), yAxisScale = yAxisScale, - horizontalLinesOffset = horizontalLinesOffset ) verticalGridLines = grid.verticalLines horizontalGridLines = grid.horizontalLines @@ -155,7 +150,7 @@ fun BarChart( XAxisLabels( labels = data.categories.map { it.name }, verticalGridLines = verticalGridLines, - xAxisMarkerLayout = xAxisLabel, + xAxisMarkerLayout = xAxisMarkerLayout, ) } @@ -173,13 +168,20 @@ private fun XAxisLabels( xAxisMarkerLayout: @Composable (value: String) -> Unit, ) { Box(Modifier.fillMaxWidth()) { + var rightEdgeOfPrevious = -1f verticalGridLines .dropLast(1) .forEachIndexed { i, verticalLine -> if (i % 2 == 1) { Box( modifier = Modifier - .alignCenterToOffsetHorizontal(verticalLine.position) + .alignCenterToOffsetHorizontal( + offsetToAlignWith = verticalLine.position, + rightEdgeOfLeftElement = rightEdgeOfPrevious, + updateRightEdge = { newValue -> + rightEdgeOfPrevious = newValue + } + ) ) { xAxisMarkerLayout( labels.getOrElse(i / 2) { "" } @@ -195,7 +197,7 @@ private fun BoxWithConstraintsScope.SelectedValueLabel( position: PointF, data: BarChartBar, colors: BarChartColors, - overlayDataEntryLabel: @Composable (dataName: String, value: Any) -> Unit + overlayDataEntryLabel: @Composable (dataName: String, dataNameShort: String?, dataUnit: String?, value: Any) -> Unit ) { OverlayInformation( positionX = position.x, @@ -205,9 +207,13 @@ private fun BoxWithConstraintsScope.SelectedValueLabel( maxHeight.toPx() ) }, - surfaceColor = colors.surface, - touchOffset = LocalDensity.current.run { position.y.toDp() }, - ) { - overlayDataEntryLabel(data.data.x, data.data.y) - } + backgroundColor = colors.overlaySurface, + touchOffsetVertical = LocalDensity.current.run { position.y!!.toDp() }, + touchOffsetHorizontal = 20.dp, + content = { + overlayDataEntryLabel(data.data.x, null, null, data.data.y) + }, + requiredOverlayWidth = 200.dp, + overlayAlpha = 0.9f, + ) } diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartColors.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartColors.kt index 1c592da..9692574 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartColors.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartColors.kt @@ -8,6 +8,7 @@ import com.netguru.multiplatform.charts.theme.ChartColors data class BarChartColors( val grid: Color, val surface: Color, + val overlaySurface: Color = surface, ) val ChartColors.barChartColors diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartConfig.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartConfig.kt index e6b7b95..545ead9 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartConfig.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartConfig.kt @@ -2,7 +2,7 @@ package com.netguru.multiplatform.charts.bar import androidx.compose.runtime.Immutable import androidx.compose.ui.unit.Dp -import com.netguru.multiplatform.charts.grid.GridDefaults +import com.netguru.multiplatform.charts.grid.ChartGridDefaults /** * The customization parameters for [BarChart] @@ -18,6 +18,6 @@ data class BarChartConfig( val thickness: Dp = BarChartDefaults.BAR_THICKNESS, val cornerRadius: Dp = BarChartDefaults.BAR_CORNER_RADIUS, val barsSpacing: Dp = BarChartDefaults.BAR_HORIZONTAL_SPACING, - val maxHorizontalLinesCount: Int = GridDefaults.NUMBER_OF_GRID_LINES, - val roundMinMaxClosestTo: Int = GridDefaults.ROUND_MIN_MAX_CLOSEST_TO, + val maxHorizontalLinesCount: Int = ChartGridDefaults.NUMBER_OF_GRID_LINES, + val roundMinMaxClosestTo: Float = ChartGridDefaults.ROUND_Y_AXIS_MARKERS_CLOSEST_TO, ) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartData.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartData.kt index fc15db9..76baf6a 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartData.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartData.kt @@ -8,6 +8,7 @@ import com.netguru.multiplatform.charts.line.SymbolShape @Immutable data class BarChartData( val categories: List, + val unit: String?, ) : GridChartData { // TODO hide those values from the user override val minX: Long = 0 @@ -40,9 +41,10 @@ data class BarChartData( .map { LegendItemData( name = it.x, + unit = unit, symbolShape = SymbolShape.RECTANGLE, color = it.color, - dashed = false, + pathEffect = null, ) } } diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartWithLegend.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartWithLegend.kt index e0f52c0..674495a 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartWithLegend.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bar/BarChartWithLegend.kt @@ -3,8 +3,10 @@ package com.netguru.multiplatform.charts.bar import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.netguru.multiplatform.charts.ChartAnimation -import com.netguru.multiplatform.charts.grid.GridDefaults +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.grid.ChartGridDefaults import com.netguru.multiplatform.charts.line.ChartLegend import com.netguru.multiplatform.charts.theme.ChartTheme @@ -20,12 +22,14 @@ import com.netguru.multiplatform.charts.theme.ChartTheme fun BarChartWithLegend( data: BarChartData, modifier: Modifier = Modifier, - animation: ChartAnimation = ChartAnimation.Simple(), + animation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), colors: BarChartColors = ChartTheme.colors.barChartColors, config: BarChartConfig = BarChartConfig(), - xAxisLabel: @Composable (value: Any) -> Unit = GridDefaults.XAxisLabel, - yAxisLabel: @Composable (value: Any) -> Unit = GridDefaults.YAxisLabel, - legendItemLabel: @Composable (String) -> Unit = GridDefaults.LegendItemLabel, + xAxisLabel: @Composable (value: Any) -> Unit = ChartGridDefaults.XAxisMarkerLayout, + yAxisLabel: @Composable (value: Any) -> Unit = ChartGridDefaults.YAxisMarkerLayout, + yAxisLabelLayout: (@Composable () -> Unit)? = null, + legendItemLabel: @Composable (name: String, unit: String?) -> Unit = ChartGridDefaults.LegendItemLabel, + columnMinWidth: Dp = 200.dp, ) { Column(modifier) { BarChart( @@ -34,14 +38,16 @@ fun BarChartWithLegend( animation = animation, colors = colors, config = config, - xAxisLabel = xAxisLabel, - yAxisLabel = yAxisLabel, + xAxisMarkerLayout = xAxisLabel, + yAxisMarkerLayout = yAxisLabel, + yAxisLabelLayout = yAxisLabelLayout, ) ChartLegend( legendData = data.legendData, animation = animation, config = config, legendItemLabel = legendItemLabel, + columnMinWidth = columnMinWidth, ) } } diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bubble/BubbleChart.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bubble/BubbleChart.kt index edf5a23..cb2a1ec 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bubble/BubbleChart.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/bubble/BubbleChart.kt @@ -1,6 +1,5 @@ package com.netguru.multiplatform.charts.bubble -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -18,9 +17,9 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import com.netguru.multiplatform.charts.ChartAnimation -import com.netguru.multiplatform.charts.StartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation import com.netguru.multiplatform.charts.bubble.BubbleDefaults.MINIMUM_BUBBLE_RADIUS +import com.netguru.multiplatform.charts.getAnimationAlphas import com.netguru.multiplatform.charts.mapValueToDifferentRange import kotlin.math.min import kotlin.random.Random @@ -39,7 +38,7 @@ import kotlin.random.Random fun BubbleChart( bubbles: List, modifier: Modifier = Modifier, - animation: ChartAnimation = ChartAnimation.Simple(), + animation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), distanceBetweenCircles: Float = -10f, bubbleLabel: @Composable (Bubble) -> Unit = BubbleDefaults.BubbleLabel, ) { @@ -47,22 +46,11 @@ fun BubbleChart( return } - val animationPlayed = StartAnimation(animation, bubbles) - val animatedScale = when (animation) { - ChartAnimation.Disabled -> bubbles.indices.map { 1f } - is ChartAnimation.Simple -> bubbles.indices.map { - animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = animation.animationSpec() - ).value - } - is ChartAnimation.Sequenced -> bubbles.indices.map { - animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = animation.animationSpec(it) - ).value - } - } + val animatedScale = getAnimationAlphas( + animation = animation, + numberOfElementsToAnimate = bubbles.size, + uniqueDatasetKey = bubbles, + ) BoxWithConstraints(modifier = modifier) { val size = min(maxWidth.value, maxHeight.value) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/Dial.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/Dial.kt index 0d8b475..36ed0c4 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/Dial.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/Dial.kt @@ -1,6 +1,5 @@ package com.netguru.multiplatform.charts.dial -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -8,34 +7,39 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke -import com.netguru.multiplatform.charts.ChartAnimation -import com.netguru.multiplatform.charts.StartAnimation -import com.netguru.multiplatform.charts.dial.DialDefaults.START_ANGLE +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.dial.scale.ScaleConfig +import com.netguru.multiplatform.charts.dial.scale.ScalePositions +import com.netguru.multiplatform.charts.dial.scale.drawScale +import com.netguru.multiplatform.charts.dial.scale.drawScaleLabels +import com.netguru.multiplatform.charts.getAnimationAlphas +import com.netguru.multiplatform.charts.line.Progression import com.netguru.multiplatform.charts.mapValueToDifferentRange import com.netguru.multiplatform.charts.theme.ChartTheme import kotlin.math.PI -import kotlin.math.cos -import kotlin.math.sin +import kotlin.math.roundToInt -/** - * Aspect ratio for dial is 2:1 (width:height). We want to draw only half of the circle - */ -private const val ASPECT_RATIO = 2f private const val CIRCLE_ANGLE = 360f -private const val MIN_ANGLE = 0f -private const val MAX_ANGLE = 180f /** * Draws a half-circle and colors the part of it differently to represent the value. @@ -48,83 +52,187 @@ private const val MAX_ANGLE = 180f * @param value Value to portray. * @param minValue Min value of the chart (will also be provided to [minAndMaxValueLabel]) * @param maxValue Max value of the chart (will also be provided to [minAndMaxValueLabel]) - * @param animation Animation to use. [ChartAnimation.Sequenced] throws an + * @param animation Animation to use. [ChartDisplayAnimation.Sequenced] throws an * [kotlin.UnsupportedOperationException], since there is only one value to display. - * @param colors Colors to be used for the chart. [DialColors] + * @param colors Colors to be used for the chart. [DialChartColors] * @param config The parameters for chart appearance customization. * @param minAndMaxValueLabel Composable to represent the [minValue] and [maxValue] on the bottom * left and right of the chart. * @param mainLabel Composable to show in the centre of the chart, showing the [value]. * - * @throws kotlin.UnsupportedOperationException when [ChartAnimation.Sequenced] is used + * @throws kotlin.UnsupportedOperationException when [ChartDisplayAnimation.Sequenced] is used */ @Composable fun Dial( - value: Int, - minValue: Int, - maxValue: Int, + value: Float, + minValue: Float = 0f, + maxValue: Float = 100f, modifier: Modifier = Modifier, - animation: ChartAnimation = ChartAnimation.Simple(), - colors: DialColors = ChartTheme.colors.dialColors, + animation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), + colors: DialChartColors = DialChartDefaults.dialChartColors(), config: DialConfig = DialConfig(), - minAndMaxValueLabel: @Composable (value: Int) -> Unit = DialDefaults.MinAndMaxValueLabel, - mainLabel: @Composable (value: Int) -> Unit = DialDefaults.MainLabel, + minAndMaxValueLabel: (@Composable (value: Float) -> Unit)? = DialDefaults.MinAndMaxValueLabel, + mainLabel: (@Composable (value: Float) -> Unit)? = DialDefaults.MainLabel, + indicator: (@Composable () -> Unit)? = null, + scaleConfig: ScaleConfig? = ScaleConfig.LinearProgressionConfig(), + progression: Progression = Progression.Linear, ) { - val animationPlayed = StartAnimation(animation, value) - val animatedScale = when (animation) { - ChartAnimation.Disabled -> { - 1f - } - is ChartAnimation.Simple -> { - animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = animation.animationSpec() - ).value - } - is ChartAnimation.Sequenced -> { - throw UnsupportedOperationException("As Dial chart only shows one value, ChartAnimation.Sequenced is not supported!") - } - } + val animatedScale = getAnimationAlphas( + animation = animation, + numberOfElementsToAnimate = 1, + uniqueDatasetKey = value, + ).first() val targetProgress = value.coerceIn(minValue..maxValue) * animatedScale - Box(modifier = modifier) { - Column(modifier = Modifier.align(Alignment.Center)) { - BoxWithConstraints( - Modifier - .fillMaxWidth() - .aspectRatio(ASPECT_RATIO) - .drawBehind { - drawProgressBar( - value = targetProgress, - minValue = minValue.toFloat(), - maxValue = maxValue.toFloat(), - config = config, - progressBarColor = colors.progressBarColor, - progressBarBackgroundColor = colors.progressBarBackgroundColor, - ) + Column(modifier = modifier) { + + val fullAngle = config.fullAngleInDegrees + val sweepAngle = when (progression) { + Progression.Linear -> { + targetProgress.mapValueToDifferentRange( + minValue, + maxValue, + 0f, + fullAngle + ) + } + + is Progression.NonLinear -> { + if (targetProgress.isNaN()) { + val range = progression.anchorPoints.first() to progression.anchorPoints[1] + + minValue.mapValueToDifferentRange( + range.first.value, + range.second.value, + fullAngle * range.first.position, + fullAngle * range.second.position, + ) + + } else { + progression + .anchorPoints + .zipWithNext() + .firstOrNull { + targetProgress in it.first.value..it.second.value + } + ?.let { range -> + targetProgress.mapValueToDifferentRange( + range.first.value, + range.second.value, + fullAngle * range.first.position, + fullAngle * range.second.position, + ) + } + ?: run { + val isSmaller = targetProgress < progression.anchorPoints.minOf { it.value } + if (isSmaller) { + 0f + } else { + fullAngle + } + } + } + } + } - if (config.displayScale) { - drawScale( - color = colors.gridScaleColor, - center = Offset( - center.x, - size.height - (config.scaleLineWidth.toPx() / 2f) - ), - config = config, + val density = LocalDensity.current + var angles by remember { + mutableStateOf( + ScalePositions( + containerWidth = 0f, + containerCenterX = 0f, + scaleItems = emptyList(), + ) + ) + } + BoxWithConstraints( + Modifier + .fillMaxWidth() + .aspectRatio(config.aspectRatio) + .drawBehind { + drawProgressBar( + value = targetProgress, + sweepAngle = sweepAngle, + minValue = minValue, + maxValue = maxValue, + config = config, + progressBarColor = colors.progressBarColor, + progressBarBackgroundColor = colors.progressBarBackgroundColor, + ) + + if (scaleConfig != null) { + val scaleCenter = Offset( + center.x, + size.width / 2 - (scaleConfig.scaleLineWidth.toPx() / 2f) + ) + if (!angles.calculatedFor(size.width, scaleCenter.x)) { + angles = ScalePositions( + containerWidth = size.width, + containerCenterX = scaleCenter.x, + scaleItems = scaleConfig.calculateProgressionMarkersPositions( + config = config, + progression = progression, + density = density, + width = size.width, + center = scaleCenter, + minValue = minValue, + maxValue = maxValue, + ) ) } + drawScale( + color = colors.gridScaleColor, + scaleConfig = scaleConfig, + calculatedAngles = angles.scaleItems, + ) } - ) { - val desiredHeight = maxWidth / ASPECT_RATIO + } + ) { + if (mainLabel != null) { Box( modifier = Modifier - .align(Alignment.Center) - .padding(top = desiredHeight / 2f) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place( + x = maxWidth.roundToPx() / 2 - placeable.width / 2, + y = (maxHeight.roundToPx() / 2.5).roundToInt(), + ) + } + } ) { mainLabel(value) } } + + if (scaleConfig != null) { + drawScaleLabels(scaleConfig, angles.scaleItems) + } + + if (indicator != null) { + Box( + modifier = Modifier + .width(maxWidth / 2) + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height) { + placeable.place(0, maxWidth.roundToPx() / 2 - placeable.height / 2) + } + } + .graphicsLayer( + rotationZ = config.minAngle + sweepAngle, + transformOrigin = TransformOrigin( + pivotFractionX = 1f, + pivotFractionY = 0.5f, + ) + ) + ) { + indicator() + } + } + } + if (minAndMaxValueLabel != null) { Row( modifier = Modifier .fillMaxWidth(), @@ -139,49 +247,38 @@ fun Dial( private fun DrawScope.drawProgressBar( value: Float, + sweepAngle: Float, minValue: Float, maxValue: Float, config: DialConfig, - progressBarColor: Color, + progressBarColor: DialProgressColors, progressBarBackgroundColor: Color, ) { - val sweepAngle = value.mapValueToDifferentRange(minValue, maxValue, MIN_ANGLE, MAX_ANGLE) val thickness = config.thickness.toPx() val radius = (size.width - thickness) / 2f val circumference = (2f * PI * radius).toFloat() val thicknessInDegrees = CIRCLE_ANGLE * thickness / circumference val arcPadding = if (config.roundCorners) thicknessInDegrees / 2f else 0f val topLeftOffset = Offset(thickness / 2f, thickness / 2f) - // Arc has to be drawn on 2 * height space cause we want only half of the circle - val arcSize = Size(size.width - thickness, size.height * ASPECT_RATIO - thickness) + // Arc has to be drawn on 2 * height space because we want only half of the circle + val arcSize = Size(size.width - thickness, size.height * config.aspectRatio - thickness) val style = Stroke( width = thickness, cap = config.strokeCap, pathEffect = PathEffect.cornerPathEffect(20f) ) - val joinStyle = if (value == minValue || value == maxValue) + val joinStyle = if (config.joinStyle != DialJoinStyle.Overlapped && (value == minValue || value == maxValue)) { DialJoinStyle.Joined - else + } else { config.joinStyle - - if (value > minValue) { - drawArc( - color = progressBarColor, - startAngle = START_ANGLE + arcPadding, - sweepAngle = sweepAngle - (2f * arcPadding), - useCenter = false, - style = style, - topLeft = topLeftOffset, - size = arcSize - ) } - if (value < maxValue) { + if (value < maxValue || !value.isFinite()) { drawArc( color = progressBarBackgroundColor, - startAngle = START_ANGLE + sweepAngle + joinStyle.startAnglePadding(arcPadding), - sweepAngle = (MAX_ANGLE - sweepAngle - joinStyle.sweepAnglePadding(arcPadding)) + startAngle = config.startAngle + joinStyle.startAnglePadding(sweepAngle, arcPadding), + sweepAngle = (config.fullAngleInDegrees - joinStyle.sweepAnglePadding(sweepAngle, arcPadding)) .coerceAtLeast(0f), useCenter = false, style = style, @@ -189,85 +286,108 @@ private fun DrawScope.drawProgressBar( size = arcSize ) } -} -private const val MAX_LINE_LENGTH = 0.20f -private const val MINOR_SCALE_ALPHA = 0.5f -private const val MINOR_SCALE_LENGTH_FACTOR = 0.35f -private const val SCALE_STEP = 2 -private const val MAJOR_SCALE_MODULO = 5 * SCALE_STEP -private fun DrawScope.drawScale( - color: Color, - center: Offset, - config: DialConfig, -) { - val scaleLineLength = (config.scaleLineLength.toPx() / center.x).coerceAtMost(MAX_LINE_LENGTH) - val scalePadding = (config.thickness.toPx() + config.scalePadding.toPx()) / center.x - val startRadiusFactor = 1 - scalePadding - scaleLineLength - val endRadiusFactor = startRadiusFactor + scaleLineLength - val smallLineRadiusFactor = scaleLineLength * MINOR_SCALE_LENGTH_FACTOR - val scaleMultiplier = size.width / 2f + if (value >= minValue) { + when (progressBarColor) { + is DialProgressColors.Gradient -> { + // we need to map those colors into range [0.5, 1.0] because drawing of the sweepGradient always starts + // at 3 o'clock and advances clockwise until it reaches 3 o'clock again. + // For the same reason, centre of the gradient is set to bottom edge. This might result in a weird color + // transition (not aligned with centre of the dial), but at least there is no jump of colors at + // 3 o'clock position. Trying to get sweepGradient to draw from [0.5, >1.0] does not work. It simply + // stops at 1.0 (100%) + val step = 0.5f / (progressBarColor.colors.size - 1) + val colorStops = progressBarColor.colors + .mapIndexed { index, color -> + val stop = 0.5f + (index * step) + stop to color + } + drawArc( + brush = Brush.sweepGradient( + colorStops = colorStops.toTypedArray(), + center = Offset( + x = size.width / 2, + y = size.height, + ), + ), + startAngle = config.startAngle + arcPadding, + sweepAngle = (sweepAngle - (2f * arcPadding)).coerceAtLeast(0f), + useCenter = false, + style = style, + topLeft = topLeftOffset, + size = arcSize + ) + } - for (point in 0..100 step SCALE_STEP) { - val angle = ( - point.toFloat() - .mapValueToDifferentRange( - 0f, - 100f, - START_ANGLE, - 0f + is DialProgressColors.GradientWithStops -> { + // we need to map those colors into range [0.5, 1.0] because drawing of the sweepGradient always starts + // at 3 o'clock and advances clockwise until it reaches 3 o'clock again. + // For the same reason, centre of the gradient is set to bottom edge. This might result in a weird color + // transition (not aligned with centre of the dial), but at least there is no jump of colors at + // 3 o'clock position. Trying to get sweepGradient to draw from [0.5, > 1.0] does not work. It simply + // stops at 1.0 (100%) + val mappedColorStops = progressBarColor + .colorStops + .map { + (it.first / 2f) + 0.5f to it.second + } + drawArc( + brush = Brush.sweepGradient( + colorStops = mappedColorStops.toTypedArray(), + center = Offset( + x = size.width / 2, + y = size.height, + ), + ), + startAngle = config.startAngle + arcPadding, + sweepAngle = (sweepAngle - (2f * arcPadding)).coerceAtLeast(0f), + useCenter = false, + style = style, + topLeft = topLeftOffset, + size = arcSize ) - ) * PI.toFloat() / 180f // to radians - val startPos = point.position( - angle, - scaleMultiplier, - startRadiusFactor, - smallLineRadiusFactor - ) - val endPos = point.position( - angle, - scaleMultiplier, - endRadiusFactor, - -smallLineRadiusFactor - ) - drawLine( - color = if (point % MAJOR_SCALE_MODULO == 0) - color - else - color.copy(alpha = MINOR_SCALE_ALPHA), - start = center + startPos, - end = center + endPos, - strokeWidth = config.scaleLineWidth.toPx(), - cap = StrokeCap.Round - ) - } -} + } -private fun Int.position( - angle: Float, - scaleMultiplier: Float, - radiusFactor: Float, - minorRadiusFactor: Float -): Offset { - val pointRadiusFactor = if (this % MAJOR_SCALE_MODULO == 0) - radiusFactor - else - radiusFactor + minorRadiusFactor - val scaledRadius = scaleMultiplier * pointRadiusFactor - return Offset(cos(angle) * scaledRadius, sin(angle) * scaledRadius) + is DialProgressColors.SingleColor -> { + drawArc( + color = progressBarColor.color, + startAngle = config.startAngle + arcPadding, + sweepAngle = (sweepAngle - (2f * arcPadding)).coerceAtLeast(0f), + useCenter = false, + style = style, + topLeft = topLeftOffset, + size = arcSize + ) + } + } + } } private val DialConfig.strokeCap: StrokeCap get() = if (roundCorners) StrokeCap.Round else StrokeCap.Butt -private fun DialJoinStyle.startAnglePadding(arcPadding: Float) = when (this) { - is DialJoinStyle.Joined -> arcPadding - is DialJoinStyle.Overlapped -> -2f * arcPadding - is DialJoinStyle.WithDegreeGap -> degrees + arcPadding +private fun DialJoinStyle.startAnglePadding(sweepAngle: Float, arcPadding: Float) = when (this) { + is DialJoinStyle.Joined -> sweepAngle + (arcPadding) + is DialJoinStyle.Overlapped -> arcPadding + is DialJoinStyle.WithDegreeGap -> sweepAngle + (degrees + arcPadding) } -private fun DialJoinStyle.sweepAnglePadding(arcPadding: Float) = when (this) { - is DialJoinStyle.Joined -> 2f * arcPadding - is DialJoinStyle.Overlapped -> -arcPadding - is DialJoinStyle.WithDegreeGap -> degrees + (2f * arcPadding) +private fun DialJoinStyle.sweepAnglePadding(sweepAngle: Float, arcPadding: Float) = when (this) { + is DialJoinStyle.Joined -> sweepAngle + (2f * arcPadding) + is DialJoinStyle.Overlapped -> 2f * arcPadding + is DialJoinStyle.WithDegreeGap -> sweepAngle + (degrees + (2f * arcPadding)) +} + +object DialChartDefaults { + @Composable + fun dialChartColors( + progressBarColor: DialProgressColors = DialProgressColors.SingleColor(ChartTheme.colors.primary), + progressBarBackgroundColor: Color = ChartTheme.colors.grid, + gridScaleColor: Color = ChartTheme.colors.grid, + ): DialChartColors = DialChartColors( + progressBarColor = progressBarColor, + progressBarBackgroundColor = progressBarBackgroundColor, + gridScaleColor = gridScaleColor, + ) + } diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialChartColors.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialChartColors.kt new file mode 100644 index 0000000..6502871 --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialChartColors.kt @@ -0,0 +1,19 @@ +package com.netguru.multiplatform.charts.dial + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import com.netguru.multiplatform.charts.theme.ChartColors + +@Immutable +data class DialChartColors( + val progressBarColor: DialProgressColors, + val progressBarBackgroundColor: Color, + val gridScaleColor: Color, +) + +@Immutable +sealed class DialProgressColors { + data class SingleColor(val color: Color) : DialProgressColors() + data class GradientWithStops(val colorStops: List>) : DialProgressColors() + data class Gradient(val colors: List) : DialProgressColors() +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialColors.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialColors.kt deleted file mode 100644 index 20617da..0000000 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialColors.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.netguru.multiplatform.charts.dial - -import androidx.compose.runtime.Immutable -import androidx.compose.ui.graphics.Color -import com.netguru.multiplatform.charts.theme.ChartColors - -@Immutable -data class DialColors( - val progressBarColor: Color, - val progressBarBackgroundColor: Color, - val gridScaleColor: Color, -) - -val ChartColors.dialColors - get() = DialColors( - progressBarColor = primary, - progressBarBackgroundColor = grid, - gridScaleColor = grid - ) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialConfig.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialConfig.kt index 60dbc6d..c8f13e3 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialConfig.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialConfig.kt @@ -1,26 +1,43 @@ package com.netguru.multiplatform.charts.dial import androidx.compose.ui.unit.Dp +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt /** * The customization parameters for [Dial] * * @param thickness The width of arc - * @param scalePadding Size of space between arc and scale - * @param scaleLineWidth Thickness of scale lines - * @param scaleLineLength The length of majors scale lines. * The maximum value is 20% width of the chart * @param joinStyle How the lib should draw space between major arc and minor arc [DialJoinStyle] */ data class DialConfig( val thickness: Dp = DialDefaults.THICKNESS, - val scalePadding: Dp = DialDefaults.SCALE_PADDING, - val scaleLineWidth: Dp = DialDefaults.SCALE_STROKE_WIDTH, - val scaleLineLength: Dp = DialDefaults.SCALE_STROKE_LENGTH, val joinStyle: DialJoinStyle = DialDefaults.JOIN_STYLE, - val displayScale: Boolean = true, val roundCorners: Boolean = false, -) + val fullAngleInDegrees: Float = 180f, +) { + val minAngle = 90 - (fullAngleInDegrees / 2) + val maxAngle = minAngle + fullAngleInDegrees + + val startAngle = minAngle + 180f + val endAngle = maxAngle + 180f + + val aspectRatio: Float = run { + // diameter is equal to 1f since we are calculating aspect ratio + // formulas can be found here: https://www.mathopenref.com/sagitta.html + val arcLength = (fullAngleInDegrees / 360f) * Math.PI.toFloat() + val halfChordLength = 0.5f * sin(arcLength) + val sagitta = if (fullAngleInDegrees <= 180f) { + 0.5f - sqrt(0.25f - halfChordLength.pow(2)) + } else { + 0.5f + sqrt(0.25f - halfChordLength.pow(2)) + } + + 1f / sagitta + } +} sealed class DialJoinStyle { object Joined : DialJoinStyle() diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialDefaults.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialDefaults.kt index faed3b8..fc8d6c6 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialDefaults.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/DialDefaults.kt @@ -14,7 +14,6 @@ internal object DialDefaults { val SCALE_PADDING = 24.dp val SCALE_STROKE_WIDTH = 2.dp val SCALE_STROKE_LENGTH = 16.dp - const val START_ANGLE = -180f val JOIN_STYLE = DialJoinStyle.WithDegreeGap(2f) val MainLabel: @Composable (value: Any) -> Unit = { diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/PercentageDial.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/PercentageDial.kt index 42c0b2a..772570f 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/PercentageDial.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/PercentageDial.kt @@ -2,8 +2,8 @@ package com.netguru.multiplatform.charts.dial import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import com.netguru.multiplatform.charts.ChartAnimation -import com.netguru.multiplatform.charts.theme.ChartTheme +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.line.Progression /** * Variant of [Dial] with min value set to 0 and max value set to 100. @@ -12,25 +12,32 @@ import com.netguru.multiplatform.charts.theme.ChartTheme * * @see Dial */ +@Deprecated( + message = "The default Dial param values are the same as the ones for PercentageDial", + replaceWith = ReplaceWith("Dial(value = percentage, modifier = modifier, animation = animation, colors = colors, config = config, minAndMaxValueLabel = minAndMaxValueLabel, mainLabel = mainLabel, indicator = indicator)") +) @Composable fun PercentageDial( - percentage: Int, + percentage: Float, modifier: Modifier = Modifier, - animation: ChartAnimation = ChartAnimation.Simple(), - colors: DialColors = ChartTheme.colors.dialColors, + animation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), + colors: DialChartColors = DialChartDefaults.dialChartColors(), config: DialConfig = DialConfig(), - minAndMaxValueLabel: @Composable (value: Int) -> Unit = DialDefaults.MinAndMaxValueLabel, - mainLabel: @Composable (value: Int) -> Unit = DialDefaults.MainLabel, + minAndMaxValueLabel: (@Composable (value: Float) -> Unit)? = DialDefaults.MinAndMaxValueLabel, + mainLabel: @Composable (value: Float) -> Unit = DialDefaults.MainLabel, + indicator: (@Composable () -> Unit)? = null, ) { Dial( value = percentage, - minValue = 0, - maxValue = 100, + minValue = 0f, + maxValue = 100f, modifier = modifier, animation = animation, colors = colors, config = config, minAndMaxValueLabel = minAndMaxValueLabel, mainLabel = mainLabel, + indicator = indicator, + progression = Progression.Linear, ) } diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/DrawScale.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/DrawScale.kt new file mode 100644 index 0000000..1370c60 --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/DrawScale.kt @@ -0,0 +1,81 @@ +package com.netguru.multiplatform.charts.dial.scale + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PointMode +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.IntOffset +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sin + + +internal fun DrawScope.drawScale( + color: Color, + scaleConfig: ScaleConfig, + calculatedAngles: List, +) { + when (scaleConfig.markType) { + MarkType.Line -> { + for (angle in calculatedAngles as List) { + drawLine( + color = color, + start = angle.startOffset, + end = angle.endOffset, + strokeWidth = scaleConfig.scaleLineWidth.toPx(), + cap = StrokeCap.Round + ) + } + } + + MarkType.Dot -> { + calculatedAngles as List + drawPoints( + points = calculatedAngles.map { it.offset }, + pointMode = PointMode.Points, + color = color, + cap = StrokeCap.Round, + strokeWidth = scaleConfig.scaleLineLength.toPx(), + ) + } + } +} + +@Composable +fun drawScaleLabels( + scaleConfig: ScaleConfig, + provideLabels: List, +) { + provideLabels + .filter { it.showLabel } + .forEach { scaleItem -> + Box( + modifier = Modifier + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + + val topLeft = when (scaleItem) { + is ScalePositions.ScaleItem.Dot -> scaleItem.offset + is ScalePositions.ScaleItem.Line -> scaleItem.startOffset + } - Offset( + x = cos(scaleItem.angle / 2).pow(2f) * placeable.width, + y = ((sin(scaleItem.angle) / 2) + (1 / 2f)) * placeable.height, + ) + + layout(placeable.width, placeable.height) { + placeable.place(IntOffset(topLeft.x.roundToInt(), topLeft.y.roundToInt())) + } + } + ) { + if (scaleItem.showLabel && scaleConfig.scaleLabelLayout != null) { + scaleConfig.scaleLabelLayout.invoke(scaleItem.value) + } + } + } +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/MarkType.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/MarkType.kt new file mode 100644 index 0000000..c6bb840 --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/MarkType.kt @@ -0,0 +1,6 @@ +package com.netguru.multiplatform.charts.dial.scale + +enum class MarkType { + Line, + Dot, +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/ScaleConfig.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/ScaleConfig.kt new file mode 100644 index 0000000..50caf6e --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/ScaleConfig.kt @@ -0,0 +1,260 @@ +package com.netguru.multiplatform.charts.dial.scale + +import androidx.compose.runtime.Composable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import com.netguru.multiplatform.charts.dial.DialConfig +import com.netguru.multiplatform.charts.dial.DialDefaults +import com.netguru.multiplatform.charts.line.Progression +import com.netguru.multiplatform.charts.mapValueToDifferentRange +import com.netguru.multiplatform.charts.toRadians +import kotlin.math.cos +import kotlin.math.sin + +/** + * Configuration for the scale + * + * @param scalePadding Size of space between arc and scale + * @param scaleLineWidth Thickness of scale lines + * @param scaleLineLength The length of majors scale lines. + */ +sealed class ScaleConfig( + val scalePadding: Dp, + val scaleLineWidth: Dp, + val scaleLineLength: Dp, + val scaleLabelLayout: (@Composable (Float) -> Unit)?, + val markType: MarkType, +) { + /** + * @param smallMarkStep How often small scale marker should be shown. Value represents diff between actual dial chart + * values. Null means it won't be shown + * @param bigMarkStep How often big scale marker should be shown. Value represents diff between actual dial chart + * values. Null means it won't be shown + */ + class LinearProgressionConfig( + scalePadding: Dp = DialDefaults.SCALE_PADDING, + scaleLineWidth: Dp = DialDefaults.SCALE_STROKE_WIDTH, + scaleLineLength: Dp = DialDefaults.SCALE_STROKE_LENGTH, + scaleLabelLayout: (@Composable (Float) -> Unit)? = null, + markType: MarkType = MarkType.Line, + val smallMarkStep: Float? = 2f, + val bigMarkStep: Float? = 10f, + ) : ScaleConfig(scalePadding, scaleLineWidth, scaleLineLength, scaleLabelLayout, markType) + + class NonLinearProgressionConfig( + scalePadding: Dp = DialDefaults.SCALE_PADDING, + scaleLineWidth: Dp = DialDefaults.SCALE_STROKE_WIDTH, + scaleLineLength: Dp = DialDefaults.SCALE_STROKE_LENGTH, + scaleLabelLayout: (@Composable (Float) -> Unit)? = null, + markType: MarkType = MarkType.Line, + ) : ScaleConfig(scalePadding, scaleLineWidth, scaleLineLength, scaleLabelLayout, markType) + + fun calculateProgressionMarkersPositions( + config: DialConfig, + progression: Progression, + density: Density, + width: Float, + center: Offset, + minValue: Float, + maxValue: Float, + ): List { + val scaleLineLength = + with(density) { (scaleLineLength.toPx() / center.x).coerceAtMost(MAX_LINE_LENGTH) } // todo remove this constant + val scalePadding = with(density) { (config.thickness.toPx() + scalePadding.toPx()) / center.x } + val startRadiusFactor = 1 - scalePadding - scaleLineLength + val endRadiusFactor = startRadiusFactor + scaleLineLength + val smallLineRadiusFactor = scaleLineLength * MINOR_SCALE_LENGTH_FACTOR // todo move this to params + val scaleMultiplier = width / 2f + + return when (this) { + is LinearProgressionConfig -> { + fun position( + angle: Float, + scaleMultiplier: Float, + radiusFactor: Float, + minorRadiusFactor: Float, + isBigMarker: Boolean, + ): Offset { + val pointRadiusFactor = + if (isBigMarker) { + radiusFactor + } else { + radiusFactor + minorRadiusFactor + } + val scaledRadius = scaleMultiplier * pointRadiusFactor + return Offset(cos(angle) * scaledRadius, sin(angle) * scaledRadius) + } + + val itemsList = mutableListOf() + + // big markers + if (bigMarkStep != null) { + var point = minValue + while (point <= maxValue) { + val angle = point.mapValueToDifferentRange( + minValue, + maxValue, + config.startAngle, + config.endAngle, + ).toRadians() + val startPos = position( + angle, + scaleMultiplier, + startRadiusFactor, + smallLineRadiusFactor, + true, + ) + val endPos = position( + angle, + scaleMultiplier, + endRadiusFactor, + -smallLineRadiusFactor, + true, + ) + when (markType) { + MarkType.Line -> { + ScalePositions.ScaleItem.Line( + angle = angle, + showLabel = true, + value = point, + startOffset = center + startPos, + endOffset = center + endPos, + ) + } + + MarkType.Dot -> { + ScalePositions.ScaleItem.Dot( + angle = angle, + showLabel = true, + value = point, + offset = center + endPos + ) + } + }.let { + itemsList.add(it) + } + + point += bigMarkStep + } + } + + // small markers + if (smallMarkStep != null) { + var point = minValue + while (point <= maxValue) { + val angle = point.mapValueToDifferentRange( + minValue, + maxValue, + config.startAngle, + config.endAngle, + ).toRadians() + + if (itemsList.firstOrNull { it.angle == angle } == null) { + val startPos = position( + angle, + scaleMultiplier, + startRadiusFactor, + smallLineRadiusFactor, + false, + ) + val endPos = position( + angle, + scaleMultiplier, + endRadiusFactor, + -smallLineRadiusFactor, + false, + ) + when (markType) { + MarkType.Line -> { + ScalePositions.ScaleItem.Line( + angle = angle, + showLabel = false, + value = point, + startOffset = center + startPos, + endOffset = center + endPos, + ) + } + + MarkType.Dot -> { + ScalePositions.ScaleItem.Dot( + angle = angle, + showLabel = false, + value = point, + offset = center + endPos + ) + } + }.let { + itemsList.add(it) + } + } + + point += smallMarkStep + } + } + itemsList + } + + is NonLinearProgressionConfig -> { + fun positionLine( + angle: Float, + scaleMultiplier: Float, + radiusFactor: Float, + minorRadiusFactor: Float, + ): Offset { + val pointRadiusFactor = radiusFactor + minorRadiusFactor + val scaledRadius = scaleMultiplier * pointRadiusFactor + return Offset(cos(angle) * scaledRadius, sin(angle) * scaledRadius) + } + + (progression as Progression.NonLinear).anchorPoints.map { point -> + val angle = point.position.mapValueToDifferentRange( + 0f, + 1f, + config.startAngle, + config.endAngle, + ).toRadians() + val startPos = positionLine( + angle, + scaleMultiplier, + startRadiusFactor, + smallLineRadiusFactor + ) + val endPos = positionLine( + angle, + scaleMultiplier, + endRadiusFactor, + -smallLineRadiusFactor + ) + + when (markType) { + MarkType.Line -> { + Triple(angle, center + startPos, center + endPos) + ScalePositions.ScaleItem.Line( + angle = angle, + showLabel = true, + value = point.value, + startOffset = center + startPos, + endOffset = center + endPos, + ) + } + + MarkType.Dot -> { + ScalePositions.ScaleItem.Dot( + angle = angle, + showLabel = true, + value = point.value, + offset = center + endPos + ) + } + } + } + } + } + } + + companion object { + const val MAX_LINE_LENGTH = 0.20f + const val MINOR_SCALE_LENGTH_FACTOR = 0.35f + } +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/ScalePositions.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/ScalePositions.kt new file mode 100644 index 0000000..4096e12 --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/dial/scale/ScalePositions.kt @@ -0,0 +1,44 @@ +package com.netguru.multiplatform.charts.dial.scale + +import androidx.compose.ui.geometry.Offset + +data class ScalePositions( + val containerWidth: Float, + val containerCenterX: Float, + val scaleItems: List, +) { + sealed class ScaleItem( + val angle: Float, + val showLabel: Boolean, + val value: Float, + ) { + class Dot( + angle: Float, + showLabel: Boolean, + value: Float, + val offset: Offset, + ) : ScaleItem(angle, showLabel, value) + + class Line( + angle: Float, + showLabel: Boolean, + value: Float, + val startOffset: Offset, + val endOffset: Offset, + ) : ScaleItem(angle, showLabel, value) + } + + fun calculatedFor(width: Float, centerX: Float): Boolean { + return containerWidth == width && containerCenterX == centerX + } + + override fun equals(other: Any?): Boolean { + other as ScalePositions + + return this.containerWidth == other.containerWidth && this.containerCenterX == other.containerCenterX + } + + override fun hashCode(): Int { + return containerWidth.hashCode() + containerCenterX.hashCode() + } +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/gasbottle/GasBottle.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/gasbottle/GasBottle.kt index 1e8454d..b11be9f 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/gasbottle/GasBottle.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/gasbottle/GasBottle.kt @@ -1,6 +1,5 @@ package com.netguru.multiplatform.charts.gasbottle -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.runtime.Composable @@ -18,8 +17,8 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.VectorPainter import androidx.compose.ui.graphics.vector.rememberVectorPainter -import com.netguru.multiplatform.charts.ChartAnimation -import com.netguru.multiplatform.charts.StartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.getAnimationAlphas import com.netguru.multiplatform.charts.mapValueToDifferentRange import com.netguru.multiplatform.charts.theme.ChartColors import com.netguru.multiplatform.charts.theme.ChartTheme @@ -32,34 +31,26 @@ import com.netguru.multiplatform.charts.theme.ChartTheme * [ChartColors.emptyGasBottle] based on the 'fullness' of the cylinder. * * @param percentage Value to portray - * @param animation Animation to use. [ChartAnimation.Sequenced] throws an + * @param animation Animation to use. [ChartDisplayAnimation.Sequenced] throws an * [kotlin.UnsupportedOperationException], since there is only one value to display. * @param colors Allows to specify full and empty bottle color * - * @throws kotlin.UnsupportedOperationException when [ChartAnimation.Sequenced] is used + * @throws kotlin.UnsupportedOperationException when [ChartDisplayAnimation.Sequenced] is used */ @Composable fun GasBottle( percentage: Float, modifier: Modifier = Modifier, - animation: ChartAnimation = ChartAnimation.Simple(), + animation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), colors: GasBottleColors = ChartTheme.colors.gasBottleColors, ) { - val animationPlayed = StartAnimation(animation, percentage) - val targetProgress = when (animation) { - ChartAnimation.Disabled -> { - percentage - } - is ChartAnimation.Simple -> { - animateFloatAsState( - targetValue = if (animationPlayed) percentage else 0f, - animationSpec = animation.animationSpec() - ).value - } - is ChartAnimation.Sequenced -> { - throw UnsupportedOperationException("As GasBottle chart only shows one value, ChartAnimation.Sequenced is not supported!") - } - } + val animationPercentage = getAnimationAlphas( + animation = animation, + numberOfElementsToAnimate = 1, + uniqueDatasetKey = percentage, + ).first() + + val targetProgress = percentage * animationPercentage val gasTank = rememberVectorPainter(image = GasTank) Box(modifier = modifier, contentAlignment = Alignment.Center) { diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/ChartAxis.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/ChartAxis.kt index 458744c..a83125b 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/ChartAxis.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/ChartAxis.kt @@ -1,36 +1,79 @@ package com.netguru.multiplatform.charts.grid +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.dp +import com.netguru.multiplatform.charts.line.XAxisConfig import kotlin.math.roundToInt +data class YAxisTitleData( + val labelLayout: @Composable () -> Unit, + val labelPosition: LabelPosition = LabelPosition.Left, +) { + enum class LabelPosition { + Top, + Left, + Right, + } +} + @Composable internal fun YAxisLabels( horizontalGridLines: List, yAxisMarkerLayout: @Composable (value: Number) -> Unit, + yAxisTitleData: YAxisTitleData?, + modifier: Modifier, ) { - Box( - modifier = Modifier - .width(IntrinsicSize.Max) - .fillMaxHeight() - .padding(end = 8.dp) - ) { - horizontalGridLines.forEach { horizontalLine -> + val markersLayout: @Composable () -> Unit = { + Box( + modifier = Modifier + .width(IntrinsicSize.Max) + .fillMaxHeight() + ) { + horizontalGridLines.forEach { horizontalLine -> + Box( + modifier = Modifier + .alignCenterToOffsetVertical(horizontalLine.position) + ) { + yAxisMarkerLayout(horizontalLine.value) + } + } + } + } + + val labelLayout: @Composable (() -> Unit)? = yAxisTitleData?.let { + { Box( - modifier = Modifier - .alignCenterToOffsetVertical(horizontalLine.position) + contentAlignment = Alignment.Center, ) { - yAxisMarkerLayout(horizontalLine.value) + yAxisTitleData.labelLayout() } } } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .fillMaxHeight() + ) { + if (yAxisTitleData?.labelPosition == YAxisTitleData.LabelPosition.Left) { + labelLayout!!() + } + markersLayout() + if (yAxisTitleData?.labelPosition == YAxisTitleData.LabelPosition.Right) { + labelLayout!!() + } + } } private fun Modifier.alignCenterToOffsetVertical( @@ -46,11 +89,88 @@ private fun Modifier.alignCenterToOffsetVertical( internal fun Modifier.alignCenterToOffsetHorizontal( offsetToAlignWith: Float, + rightEdgeOfLeftElement: Float, + updateRightEdge: (newValue: Float) -> Unit, ) = layout { measurable, constraints -> val placeable = measurable.measure(constraints) val placeableX = offsetToAlignWith - (placeable.width / 2f) - layout(placeable.width, placeable.height) { - placeable.placeRelative(placeableX.roundToInt(), 0) + if (placeableX > rightEdgeOfLeftElement) { + updateRightEdge(placeableX.roundToInt().toFloat() + placeable.width) + layout(placeable.width, placeable.height) { + placeable.placeRelative(placeableX.roundToInt(), 0) + } + } else { + layout(0, 0) {} + } +} + +@Composable +internal fun DrawXAxisMarkers( + lineParams: List, + xAxisConfig: XAxisConfig, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier, + ) { + Layout( + modifier = Modifier, + content = { + lineParams.forEach { + xAxisConfig.markerLayout(it.value.toLong()) + } + }, + ) { measurables, constraints -> + val placeables = measurables.map { + it.measure(constraints) + } + + layout(width = constraints.maxWidth, height = placeables.maxOfOrNull { it.height } ?: 0) { + var leftEdge = 0 + var rightEdge = 0 + + val placeablesLeftToPlace = if (placeables.size > 1) { + placeables.last().let { + val xPos = + lineParams.last().position.roundToInt() - if (xAxisConfig.alignFirstAndLastToChartEdges) { + it.width + } else { + it.width / 2 + } + rightEdge = xPos + + it.placeRelative( + x = xPos, + y = 0, + ) + } + + placeables.subList( + fromIndex = 0, + toIndex = placeables.lastIndex, + ) + } else { + placeables + } + + placeablesLeftToPlace.forEachIndexed { index, placeable -> + val xPos = + lineParams[index].position.roundToInt() - if (index == 0 && xAxisConfig.alignFirstAndLastToChartEdges) { + 0 + } else { + (placeable.width / 2) + } + val xPosEnd = xPos + placeable.width + if (!xAxisConfig.hideMarkersWhenOverlapping || (xPos > leftEdge && xPosEnd < rightEdge) || index == 0) { + placeable.placeRelative( + x = xPos, + y = 0, + ) + leftEdge = xPos + placeable.width + } + } + } + } } } diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/ChartGridDefaults.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/ChartGridDefaults.kt new file mode 100644 index 0000000..83c6ac4 --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/ChartGridDefaults.kt @@ -0,0 +1,86 @@ +package com.netguru.multiplatform.charts.grid + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.sp +import com.netguru.multiplatform.charts.grid.axisscale.y.YAxisScaleDynamic +import com.netguru.multiplatform.charts.line.YAxisConfig +import com.netguru.multiplatform.charts.vertical + +internal object ChartGridDefaults { + + const val NUMBER_OF_GRID_LINES = 5 + const val ROUND_Y_AXIS_MARKERS_CLOSEST_TO = 10f + const val ROUND_X_AXIS_MARKERS_CLOSEST_TO = 15 * 60 * 1000L // 15 minutes + + val YAxisMarkerLayout: @Composable (value: Any) -> Unit = { value -> + Text( + modifier = Modifier + .fillMaxWidth(), + fontSize = 12.sp, + text = value.toString(), + textAlign = TextAlign.End, + maxLines = 1 + ) + } + + val YAxisDataTitleLayout: @Composable () -> Unit = { + Text( + fontSize = 12.sp, + text = "y-axis label", + maxLines = 1, + modifier = Modifier + .vertical() + ) + } + + val YAxisDataTitle: YAxisTitleData = YAxisTitleData( + labelLayout = YAxisDataTitleLayout, + labelPosition = YAxisTitleData.LabelPosition.Left, + ) + + val TooltipHeaderLabel: @Composable (value: Any, dataUnit: String?) -> Unit = { value, dataUnit -> + Text( + text = value.toString() + dataUnit?.let { " $it" }.orEmpty(), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.overline + ) + } + + val TooltipDataEntryLabel: @Composable (dataName: String, dataNameShort: String?, dataUnit: String?, value: Any) -> Unit = + { dataName, dataNameShort, dataUnit, value -> + Text( + text = "$dataName${dataNameShort?.let { " ($it)" }.orEmpty()}: $value" + dataUnit?.let { " $it" } + .orEmpty() + ) + } + + val XAxisMarkerLayout: @Composable (value: Any) -> Unit = { value -> + Text( + fontSize = 12.sp, + text = value.toString(), + textAlign = TextAlign.Center + ) + } + + val LegendItemLabel: @Composable (name: String, unit: String?) -> Unit = { name, unit -> + Text( + text = name + unit?.let { " ($it)" }.orEmpty(), + ) + } + + @Composable + fun yAxisConfig(data: GridChartData) = remember(data) { + YAxisConfig( + scale = YAxisScaleDynamic( + chartData = data, + ) + ) + } +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/GridChartDrawing.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/GridChartDrawing.kt index 41699b9..7713195 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/GridChartDrawing.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/GridChartDrawing.kt @@ -3,32 +3,29 @@ package com.netguru.multiplatform.charts.grid import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.unit.Dp -import com.netguru.multiplatform.charts.grid.axisscale.XAxisScale -import com.netguru.multiplatform.charts.grid.axisscale.YAxisScale +import com.netguru.multiplatform.charts.grid.axisscale.x.XAxisScale +import com.netguru.multiplatform.charts.grid.axisscale.y.YAxisScale import com.netguru.multiplatform.charts.mapValueToDifferentRange +import kotlin.math.sign -fun DrawScope.drawChartGrid(grid: ChartGrid, color: Color) { +fun DrawScope.drawChartGrid( + grid: ChartGrid, + color: Color, +) { grid.horizontalLines.forEach { drawLine( color = color, start = Offset(0f, it.position), end = Offset(size.width, it.position), - strokeWidth = 1f + strokeWidth = 1f, ) } - drawLine( - color = color, - start = Offset(0f, grid.zeroPosition.position), - end = Offset(size.width, grid.zeroPosition.position), - strokeWidth = 1f - ) grid.verticalLines.forEach { drawLine( color = color, start = Offset(it.position, 0f), end = Offset(it.position, size.height), - strokeWidth = 1f + strokeWidth = 1f, ) } } @@ -36,7 +33,6 @@ fun DrawScope.drawChartGrid(grid: ChartGrid, color: Color) { fun DrawScope.measureChartGrid( xAxisScale: XAxisScale, yAxisScale: YAxisScale, - horizontalLinesOffset: Dp ): ChartGrid { val horizontalLines = measureHorizontalLines( @@ -52,6 +48,7 @@ fun DrawScope.measureChartGrid( ) val zero = when { + yAxisScale.min == yAxisScale.max -> 0f yAxisScale.min > 0 -> yAxisScale.min yAxisScale.max < 0 -> yAxisScale.max else -> 0f @@ -61,7 +58,7 @@ fun DrawScope.measureChartGrid( horizontalLines = horizontalLines, zeroPosition = LineParameters( zero.mapValueToDifferentRange( - yAxisScale.min, + if (yAxisScale.min == yAxisScale.max) 0f else yAxisScale.min, yAxisScale.max, size.height, 0f @@ -78,15 +75,45 @@ private fun measureHorizontalLines( ): List { val horizontalLines = mutableListOf() - if (axisScale.max == axisScale.min || axisScale.tick == 0f) - return listOf( - LineParameters( - position = startPosition / 2f, - value = 0 + if (axisScale.max == axisScale.min || axisScale.step == 0f) { + return if(axisScale.max.sign != axisScale.min.sign) { + listOf( + LineParameters( + position = axisScale.max.mapValueToDifferentRange( + 0f, + axisScale.max, + startPosition, + endPosition + ), + value = axisScale.max, + ), + LineParameters( + position = axisScale.min.mapValueToDifferentRange( + axisScale.min, + 0f, + startPosition, + endPosition + ), + value = axisScale.min, + ), ) - ) - val valueStep = axisScale.tick + } else { + listOf( + LineParameters( + position = axisScale.max.mapValueToDifferentRange( + 0f, + axisScale.max, + startPosition, + endPosition + ), + value = axisScale.max, + ), + ) + } + } + + val valueStep = axisScale.step var currentValue = axisScale.min while (currentValue in axisScale.min..axisScale.max) { @@ -94,7 +121,7 @@ private fun measureHorizontalLines( axisScale.min, axisScale.max, startPosition, - endPosition + endPosition, ) horizontalLines.add( LineParameters( @@ -107,19 +134,19 @@ private fun measureHorizontalLines( return horizontalLines } -private fun measureVerticalLines( +fun measureVerticalLines( axisScale: XAxisScale, startPosition: Float, endPosition: Float ): List { val verticalLines = mutableListOf() - val valueStep = axisScale.tick + val valueStep = axisScale.tick.coerceAtLeast(1) var currentValue = axisScale.start - while (currentValue in axisScale.min..axisScale.max) { + while (currentValue in axisScale.start..axisScale.end) { val currentPosition = currentValue.mapValueToDifferentRange( - axisScale.min, - axisScale.max, + axisScale.start, + axisScale.end, startPosition, endPosition ) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/GridDefaults.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/GridDefaults.kt deleted file mode 100644 index 9560396..0000000 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/GridDefaults.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.netguru.multiplatform.charts.grid - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -internal object GridDefaults { - - val HORIZONTAL_LINES_OFFSET = 10.dp - const val NUMBER_OF_GRID_LINES = 5 - const val ROUND_MIN_MAX_CLOSEST_TO = 10 - - val YAxisLabel: @Composable (value: Any) -> Unit = { value -> - Text( - modifier = Modifier - .fillMaxWidth(), - fontSize = 12.sp, - text = value.toString(), - textAlign = TextAlign.End, - maxLines = 1 - ) - } - - val OverlayHeaderLabel: @Composable (value: Any) -> Unit = { value -> - Text( - text = value.toString(), - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - style = MaterialTheme.typography.overline - ) - } - - val OverlayDataEntryLabel: @Composable (dataName: String, value: Any) -> Unit = { dataName, value -> - Text( - text = "$dataName: $value" - ) - } - - val XAxisLabel: @Composable (value: Any) -> Unit = { value -> - Text( - fontSize = 12.sp, - text = value.toString(), - textAlign = TextAlign.Center - ) - } - - val LegendItemLabel: @Composable (String) -> Unit = { - Text( - text = it, - ) - } -} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/TimestampXAxisScale.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/TimestampXAxisScale.kt deleted file mode 100644 index b07b05f..0000000 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/TimestampXAxisScale.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.netguru.multiplatform.charts.grid.axisscale - -class TimestampXAxisScale( - override val min: Long, - override val max: Long, - val maxTicksCount: Int = 10 -) : XAxisScale { - // find first round hour, greater than min timestamp - override val start: Long = min - min % HOUR_MS + HOUR_MS - - // find period of vertical lines based on maxTicksCount, period can be in round hours ie. 1h, 2h, 3h - private val period: Long = HOUR_MS * ((max - min) / HOUR_MS / maxTicksCount) - - // if period is 0 or less set period to round hour - // avoid division by 0 when app starts and min and max are 0 - override val tick: Long = if (period > 0) period else HOUR_MS - - companion object { - const val HOUR_MS = 3600000L - } -} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/YAxisScale.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/YAxisScale.kt deleted file mode 100644 index be6e2f2..0000000 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/YAxisScale.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.netguru.multiplatform.charts.grid.axisscale - -import kotlin.math.floor -import kotlin.math.log10 -import kotlin.math.pow - -class YAxisScale( - min: Float, - max: Float, - maxTickCount: Int, - roundClosestTo: Int, -) { - val tick: Float - val min: Float - val max: Float - - init { - this.min = if (!min.isNaN()) { - min.getClosest(roundClosestTo) - } else { - 0f - } - this.max = if (!max.isNaN()) { - max.getClosest(roundClosestTo) - } else { - 0f - } - - val range = niceNum(this.max - this.min, false) - this.tick = niceNum(range / (maxTickCount), true) - } - - private fun Float.getClosest(n: Int) = when { - this > 0f -> (((this.toInt() + n - 1) / n) * n).toFloat() - this < 0f -> (((this.toInt() - n + 1) / n) * n).toFloat() - else -> 0f - } - - /** - * Returns a "nice" number approximately equal to range. - * Rounds the number if round = true Takes the ceiling if round = false. - * - * @param range the data range - * @param round whether to round the result - * @return a "nice" number to be used for the data range - */ - private fun niceNum(range: Float, round: Boolean): Float { - /** nice, rounded fraction */ - val exponent: Float = floor(log10(range)) - /** exponent of range */ - val fraction = range / 10.0f.pow(exponent) - /** fractional part of range */ - val niceFraction: Float = if (round) { - if (fraction < 1.5) 1.0f else if (fraction < 3) 2.0f else if (fraction < 7) 5.0f else 10.0f - } else { - if (fraction <= 1) 1.0f else if (fraction <= 2) 2.0f else if (fraction <= 5) 5.0f else 10.0f - } - return niceFraction * 10.0f.pow(exponent) - } -} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/FixedTicksXAxisScale.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/x/FixedTicksXAxisScale.kt similarity index 69% rename from charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/FixedTicksXAxisScale.kt rename to charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/x/FixedTicksXAxisScale.kt index 6063fad..a27f676 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/FixedTicksXAxisScale.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/x/FixedTicksXAxisScale.kt @@ -1,4 +1,4 @@ -package com.netguru.multiplatform.charts.grid.axisscale +package com.netguru.multiplatform.charts.grid.axisscale.x class FixedTicksXAxisScale( override val min: Long, @@ -7,4 +7,5 @@ class FixedTicksXAxisScale( ) : XAxisScale { override val tick: Long = (max - min) / tickCount override val start: Long = min + override val end: Long = max } diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/x/TimestampXAxisScale.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/x/TimestampXAxisScale.kt new file mode 100644 index 0000000..b1f858e --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/x/TimestampXAxisScale.kt @@ -0,0 +1,34 @@ +package com.netguru.multiplatform.charts.grid.axisscale.x + +class TimestampXAxisScale( + override val min: Long, + override val max: Long, + val maxTicksCount: Int = 10, + roundClosestTo: Long?, +) : XAxisScale { + + override val start: Long = if (roundClosestTo != null) { + (min / roundClosestTo) * roundClosestTo + } else { + min + } + + override val tick: Long + + override val end: Long + + init { + end = if (roundClosestTo != null) { + ((max / roundClosestTo) * roundClosestTo) + roundClosestTo + } else { + max + } + + val exactTick = (end - start) / maxTicksCount + tick = if (roundClosestTo != null) { + (exactTick / roundClosestTo) * roundClosestTo + } else { + exactTick + } + } +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/XAxisScale.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/x/XAxisScale.kt similarity index 57% rename from charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/XAxisScale.kt rename to charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/x/XAxisScale.kt index 5995792..a537b8e 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/XAxisScale.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/x/XAxisScale.kt @@ -1,8 +1,9 @@ -package com.netguru.multiplatform.charts.grid.axisscale +package com.netguru.multiplatform.charts.grid.axisscale.x interface XAxisScale { val tick: Long val min: Long val max: Long val start: Long + val end: Long } diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScale.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScale.kt new file mode 100644 index 0000000..62b5454 --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScale.kt @@ -0,0 +1,8 @@ +package com.netguru.multiplatform.charts.grid.axisscale.y + +interface YAxisScale { + val step: Float + val min: Float + val max: Float + val numberOfHorizontalLines: Int +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScaleDynamic.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScaleDynamic.kt new file mode 100644 index 0000000..c272ee7 --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScaleDynamic.kt @@ -0,0 +1,136 @@ +package com.netguru.multiplatform.charts.grid.axisscale.y + +import com.netguru.multiplatform.charts.grid.ChartGridDefaults +import com.netguru.multiplatform.charts.grid.GridChartData +import com.netguru.multiplatform.charts.roundToMultiplicationOf +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.roundToInt +import kotlin.math.sign + +class YAxisScaleDynamic( + chartData: GridChartData, + maxNumberOfHorizontalLines: Int = ChartGridDefaults.NUMBER_OF_GRID_LINES, + roundMarkersToMultiplicationOf: Float? = ChartGridDefaults.ROUND_Y_AXIS_MARKERS_CLOSEST_TO, + forceShowingValueZeroLine: Boolean = false, +) : YAxisScale { + override val step: Float + override val min: Float + override val max: Float + override val numberOfHorizontalLines: Int + + + private fun roundToMultiplicationOf(numberToRound: Float, multiplicandBase: Float, ceiling: Boolean): Float { + var moveDecimalPointBy = 0 + if (multiplicandBase < 1) { + var tmp = multiplicandBase + while (tmp.rem(1) > 0) { + tmp *= 10 + moveDecimalPointBy++ + } + } + + return numberToRound + .times(10f.pow(moveDecimalPointBy)) + .roundToMultiplicationOf( + multiplicand = multiplicandBase + .times(10f.pow(moveDecimalPointBy)), + roundToCeiling = ceiling, + ) + .div(10f.pow(moveDecimalPointBy)) + } + + init { + if (maxNumberOfHorizontalLines <= 0) { + throw IllegalArgumentException("maxNumberOfHorizontalLines must be positive") + } + if (roundMarkersToMultiplicationOf != null && roundMarkersToMultiplicationOf <= 0) { + throw IllegalArgumentException("roundMarkersToMultiplicationOf must be either null or a positive number") + } + val validMin = if (chartData.minY.isNaN()) { + 0f + } else { + chartData.minY + } + val validMax = if (chartData.maxY.isNaN()) { + 0f + } else { + chartData.maxY + } + + this.step = if (maxNumberOfHorizontalLines == 1) { + 0f + } else { + val maxDiffToShowLinesFor = if (validMax.sign == validMin.sign) { + validMax - validMin + } else { + max(validMax, -validMin) + } + (maxDiffToShowLinesFor / maxNumberOfHorizontalLines) + .let { + if (roundMarkersToMultiplicationOf == null) { + it + } else { + roundToMultiplicationOf(it, roundMarkersToMultiplicationOf, true) + } + } + } + + this.min = run { + val roundedMin = if (step == 0f) { + if (roundMarkersToMultiplicationOf == null) { + // do not round + validMin + } else { + roundToMultiplicationOf(validMin, roundMarkersToMultiplicationOf, false) + } + } else { + roundToMultiplicationOf(validMin, step, false) + } + + if (roundedMin > 0 && forceShowingValueZeroLine) { + 0f + } else { + roundedMin + } + } + + this.max = run { + val roundedMax = if (step == 0f) { + if (roundMarkersToMultiplicationOf == null) { + // do not round + validMax + } else { + roundToMultiplicationOf(validMax, roundMarkersToMultiplicationOf, true) + } + } else { + roundToMultiplicationOf(validMax, step, true) + } + + if (roundedMax < 0 && forceShowingValueZeroLine) { + 0f + } else { + roundedMax + } + } + + this.numberOfHorizontalLines = if (step == 0f) { + if (min.sign == max.sign) { + 1 + } else { + 2 + } + } else { + if (min.sign == max.sign) { + ((max - min) / step).roundToInt() + } else { + (max / step).roundToInt() + (-min / step).roundToInt() + } + } + } + + override fun toString(): String { + return "min: $min, max: $max, step: $step" + } +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScaleStatic.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScaleStatic.kt new file mode 100644 index 0000000..841d38d --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScaleStatic.kt @@ -0,0 +1,26 @@ +package com.netguru.multiplatform.charts.grid.axisscale.y + +import com.netguru.multiplatform.charts.grid.ChartGridDefaults +import com.netguru.multiplatform.charts.grid.GridChartData + +class YAxisScaleStatic( + override val min: Float, + override val max: Float, + override val numberOfHorizontalLines: Int = ChartGridDefaults.NUMBER_OF_GRID_LINES, +) : YAxisScale { + override val step: Float + + init { + + if (numberOfHorizontalLines <= 0) { + throw IllegalArgumentException("numberOfHorizontalLines must be positive") + } + + val range = this.max - this.min + this.step = range / numberOfHorizontalLines + } + + override fun toString(): String { + return "min: $min, max: $max, step: $step" + } +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/ChartLegend.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/ChartLegend.kt index 180d7ab..9ffcffe 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/ChartLegend.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/ChartLegend.kt @@ -1,22 +1,19 @@ package com.netguru.multiplatform.charts.line -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.GridCells -import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -24,39 +21,56 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp -import com.netguru.multiplatform.charts.ChartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation import com.netguru.multiplatform.charts.bar.BarChartConfig -import com.netguru.multiplatform.charts.grid.GridDefaults +import com.netguru.multiplatform.charts.getAnimationAlphas +import com.netguru.multiplatform.charts.grid.ChartGridDefaults -@OptIn(ExperimentalFoundationApi::class) @Composable fun ChartLegend( legendData: List, modifier: Modifier = Modifier, - animation: ChartAnimation = ChartAnimation.Simple(), + animation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), config: BarChartConfig = BarChartConfig(), - legendItemLabel: @Composable (String) -> Unit = GridDefaults.LegendItemLabel, + legendItemLabel: @Composable (name: String, unit: String?) -> Unit = ChartGridDefaults.LegendItemLabel, + columnMinWidth: Dp = 200.dp, ) { - LazyVerticalGrid( - modifier = modifier, - cells = GridCells.Adaptive(200.dp), - contentPadding = PaddingValues( - start = 12.dp, - top = 16.dp, - end = 12.dp, - bottom = 16.dp - ), + val alpha = getAnimationAlphas( + animation = animation, + numberOfElementsToAnimate = legendData.size, + uniqueDatasetKey = legendData, + ) + + BoxWithConstraints( + modifier.padding(16.dp) ) { - items(legendData.count()) { index -> - LegendItem( - data = legendData[index], - index = index, - animation = animation, - config = config, - legendItemLabel = legendItemLabel, - ) + val cols = maxOf((maxWidth / columnMinWidth).toInt(), 1) + val rows = legendData.chunked(cols) + LazyColumn { + itemsIndexed(rows) { rowIndex, legendRowItems -> + Row( + Modifier.height(IntrinsicSize.Min), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + legendRowItems.indices.forEach { colIndex -> + val index = (rowIndex * cols) + colIndex + + LegendItem( + data = legendData[index], + alpha = alpha[index], + legendItemLabel = legendItemLabel, + config = config, + modifier = Modifier + .weight(1f) + ) + } + } + } } } } @@ -64,32 +78,16 @@ fun ChartLegend( @Composable private fun LegendItem( data: LegendItemData, - index: Int, - animation: ChartAnimation, + alpha: Float, config: BarChartConfig, - legendItemLabel: @Composable (String) -> Unit, + legendItemLabel: @Composable (name: String, unit: String?) -> Unit, + modifier: Modifier = Modifier, ) { - var animationPlayed by remember(animation) { - mutableStateOf(animation is ChartAnimation.Disabled) - } - - LaunchedEffect(key1 = true) { - animationPlayed = true // to play animation only once - } - - val alpha = when (animation) { - ChartAnimation.Disabled -> 1f - is ChartAnimation.Simple -> animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = animation.animationSpec(), - ).value - is ChartAnimation.Sequenced -> animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = animation.animationSpec(index), - ).value - } - - Row(modifier = Modifier.alpha(alpha), verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .alpha(alpha) + ) { Box( modifier = Modifier .size(data.selectSymbolSize()) @@ -98,11 +96,12 @@ private fun LegendItem( SymbolShape.LINE -> drawLine( strokeWidth = size.height, - pathEffect = if (data.dashed) dashedPathEffect else null, + pathEffect = data.pathEffect, color = data.color, start = Offset(0f, size.height / 2), end = Offset(size.width, size.height / 2) ) + SymbolShape.RECTANGLE -> drawRoundRect( color = data.color, @@ -116,21 +115,22 @@ private fun LegendItem( ) Spacer(modifier = Modifier.width(8.dp)) - legendItemLabel(data.name) + legendItemLabel(data.name, data.unit) } } @Immutable data class LegendItemData( val name: String, + val unit: String?, val symbolShape: SymbolShape, val color: Color, - val dashed: Boolean, + val pathEffect: PathEffect?, ) @Composable private fun LegendItemData.selectSymbolSize() = when (symbolShape) { - SymbolShape.LINE -> DpSize(width = 16.dp, height = 4.dp) + SymbolShape.LINE -> DpSize(width = 32.dp, height = 4.dp) SymbolShape.RECTANGLE -> DpSize(width = 12.dp, height = 12.dp) } diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LegendConfig.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LegendConfig.kt new file mode 100644 index 0000000..4f1b825 --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LegendConfig.kt @@ -0,0 +1,11 @@ +package com.netguru.multiplatform.charts.line + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.netguru.multiplatform.charts.grid.ChartGridDefaults + +data class LegendConfig( + val columnMinWidth: Dp = 200.dp, + val legendItemLabel: @Composable (name: String, unit: String?) -> Unit = ChartGridDefaults.LegendItemLabel, +) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/Line.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/Line.kt index ee8a41c..42587d2 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/Line.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/Line.kt @@ -1,99 +1,193 @@ package com.netguru.multiplatform.charts.line +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PointMode +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.unit.Dp +import com.netguru.multiplatform.charts.grid.axisscale.x.TimestampXAxisScale +import com.netguru.multiplatform.charts.grid.axisscale.y.YAxisScale import com.netguru.multiplatform.charts.mapValueToDifferentRange internal fun DrawScope.drawLineChart( lineChartData: LineChartData, - graphTopPadding: Dp, - graphBottomPadding: Dp, alpha: List, + drawDots: Boolean, + selectedPointsForDrawing: List, + xAxisScale: TimestampXAxisScale, + yAxisScale: YAxisScale, + shouldInterpolateOverNullValues: Boolean, ) { // calculate path val path = Path() - lineChartData.series.forEachIndexed { seriesIndex, data -> + lineChartData.series.forEachIndexed { seriesIndex, unfilteredData -> - val mappedPoints = - mapDataToPixels( - lineChartData, - data, - size, - graphTopPadding.toPx(), - graphBottomPadding.toPx() - ) - val connectionPoints = calculateConnectionPointsForBezierCurve(mappedPoints) - - path.reset() // reuse path - mappedPoints.forEachIndexed { index, value -> - if (index == 0) { - path.moveTo(value.x, value.y) - } else { - path.cubicTo( - connectionPoints[index - 1].first.x, - connectionPoints[index - 1].first.y, - connectionPoints[index - 1].second.x, - connectionPoints[index - 1].second.y, - value.x, - value.y - ) + val filteredLists: MutableList> = mutableListOf() + if (shouldInterpolateOverNullValues) { + filteredLists.add(unfilteredData.listOfPoints.filter { it.y != null }) + } else { + var tempList: MutableList = mutableListOf() + unfilteredData.listOfPoints.forEach { lineChartPoint -> + if (lineChartPoint.y != null) { + tempList.add(lineChartPoint) + } else { + if (tempList.isNotEmpty()) { + filteredLists.add(tempList) + tempList = mutableListOf() + } + } + } + if (tempList.isNotEmpty()) { + filteredLists.add(tempList) + tempList = mutableListOf() } } - // draw line - drawPath( - path = path, - color = data.lineColor.copy(alpha[seriesIndex]), - style = Stroke( - width = data.lineWidth.toPx(), - pathEffect = if (data.dashedLine) dashedPathEffect else null - ) - ) + val (shouldSetZeroAsMinValue, shouldSetZeroAsMaxValue) = unfilteredData + .listOfPoints + .filter { it.y != null } + .distinctBy { it.y } + .takeIf { it.size == 1 } + ?.let { (it.first().y!! > 0) to (it.first().y!! < 0) } + ?: (false to false) - // close shape and fill - path.lineTo(mappedPoints.last().x, size.height) - path.lineTo(mappedPoints.first().x, size.height) - drawPath( - path = path, - Brush.verticalGradient( - listOf( - Color.Transparent, - data.fillColor.copy(alpha[seriesIndex] / 12), - data.fillColor.copy(alpha[seriesIndex] / 6) - ), - startY = path.getBounds().bottom, - endY = path.getBounds().top, - ), - style = Fill - ) + filteredLists + .map { unfilteredData.copy(listOfPoints = it) } + .forEach { data -> + if (data.listOfPoints.isEmpty()) { + return@forEachIndexed + } + val mappedPoints = + mapDataToPixels( + xAxisScale = xAxisScale, + yAxisScale = yAxisScale, + currentSeries = data, + canvasSize = size, + shouldSetZeroAsMinValue = shouldSetZeroAsMinValue, + shouldSetZeroAsMaxValue = shouldSetZeroAsMaxValue, + ) + val connectionPoints = calculateConnectionPointsForBezierCurve(mappedPoints) + + path.reset() // reuse path + mappedPoints + .filter { it.y != null } + .forEachIndexed { index, value -> + if (index == 0) { + path.moveTo(value.x, value.y!!) + } else { + val point = connectionPoints[index - 1] + path.cubicTo( + x1 = point.first.x, + y1 = point.first.y!!, + x2 = point.second.x, + y2 = point.second.y!!, + x3 = value.x, + y3 = value.y!!, + ) + } + } + + if (mappedPoints.size == 1 || drawDots) { + val pointSize = data.lineWidth.toPx().times(if (drawDots) 3f else 2f) + val widthThePointsTake = mappedPoints.maxOf { it.x } - mappedPoints.minOf { it.x } + val isEnoughSpace = + (mappedPoints.size - 2 /* this 2 is a magic number, it just works better with it */) * pointSize * 1.5 < widthThePointsTake + if (isEnoughSpace) { + drawPoints( + points = mappedPoints.filter { it.y != null }.map { Offset(it.x, it.y!!) }, + pointMode = PointMode.Points, + color = data.lineColor, + alpha = alpha[seriesIndex], + strokeWidth = pointSize, + cap = StrokeCap.Round, + ) + } + } + + if (mappedPoints.size > 1) { + // draw line + drawPath( + path = path, + color = data.lineColor.copy(alpha[seriesIndex]), + style = Stroke( + width = data.lineWidth.toPx(), + pathEffect = data.pathEffect, + ), + ) + } + + // close shape and fill + path.lineTo(mappedPoints.last().x, size.height) + path.lineTo(mappedPoints.first().x, size.height) + drawPath( + path = path, + brush = Brush.verticalGradient( + listOf( + Color.Transparent, + data.fillColor.copy(alpha[seriesIndex] / 12), + data.fillColor.copy(alpha[seriesIndex] / 6) + ), + startY = path.getBounds().bottom, + endY = path.getBounds().top, + ), + style = Fill, + ) + + if (selectedPointsForDrawing.isNotEmpty()) { + val offsets = selectedPointsForDrawing + .map { seriesAndClosestPoint -> + val x = seriesAndClosestPoint.closestPoint.x.mapValueToDifferentRange( + xAxisScale.start, + xAxisScale.end, + 0L, + size.width.toLong() + ).toFloat() + val y = seriesAndClosestPoint.closestPoint.y?.mapValueToDifferentRange( + if (shouldSetZeroAsMinValue) 0f else lineChartData.minY, + lineChartData.maxY, + size.height, + 0f, + ) + Offset(x, y!!) + } + drawPoints( + points = offsets, + pointMode = PointMode.Points, + color = data.lineColor, + alpha = alpha[seriesIndex], + strokeWidth = data.lineWidth.toPx().times(5f), + cap = StrokeCap.Round, + ) + } + } } } private fun mapDataToPixels( - lineChartData: LineChartData, currentSeries: LineChartSeries, canvasSize: Size, - graphTopPadding: Float = 0f, - graphBottomPadding: Float, + shouldSetZeroAsMinValue: Boolean, + shouldSetZeroAsMaxValue: Boolean, + xAxisScale: TimestampXAxisScale, + yAxisScale: YAxisScale, ): List { val mappedPoints = currentSeries.listOfPoints.map { val x = it.x.mapValueToDifferentRange( - lineChartData.minX, - lineChartData.maxX, - 0L, - canvasSize.width.toLong() + inMin = xAxisScale.start, + inMax = xAxisScale.end, + outMin = 0L, + outMax = canvasSize.width.toLong() ).toFloat() - val y = it.y.mapValueToDifferentRange( - lineChartData.minY, - lineChartData.maxY, - canvasSize.height - graphBottomPadding, - graphTopPadding + val y = it.y?.mapValueToDifferentRange( + inMin = if (shouldSetZeroAsMinValue) 0f else yAxisScale.min, + inMax = if (shouldSetZeroAsMaxValue) 0f else yAxisScale.max, + outMin = canvasSize.height, + outMax = 0f, ) PointF(x, y) } @@ -101,8 +195,9 @@ private fun mapDataToPixels( return mappedPoints } -private fun calculateConnectionPointsForBezierCurve(points: List): MutableList> { +private fun calculateConnectionPointsForBezierCurve(points2: List): MutableList> { val conPoint = mutableListOf>() + val points = points2.filter { it.y != null } for (i in 1 until points.size) { conPoint.add( Pair( @@ -114,4 +209,4 @@ private fun calculateConnectionPointsForBezierCurve(points: List): Mutab return conPoint } -data class PointF(val x: Float, val y: Float) +data class PointF(val x: Float, val y: Float?) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChart.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChart.kt index d66439d..b5d3dea 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChart.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChart.kt @@ -1,218 +1,270 @@ package com.netguru.multiplatform.charts.line -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.isOutOfBounds import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp -import com.netguru.multiplatform.charts.ChartAnimation -import com.netguru.multiplatform.charts.StartAnimation -import com.netguru.multiplatform.charts.grid.GridDefaults +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.getAnimationAlphas +import com.netguru.multiplatform.charts.grid.DrawXAxisMarkers +import com.netguru.multiplatform.charts.grid.ChartGridDefaults import com.netguru.multiplatform.charts.grid.LineParameters import com.netguru.multiplatform.charts.grid.YAxisLabels -import com.netguru.multiplatform.charts.grid.alignCenterToOffsetHorizontal -import com.netguru.multiplatform.charts.grid.axisscale.TimestampXAxisScale -import com.netguru.multiplatform.charts.grid.axisscale.YAxisScale +import com.netguru.multiplatform.charts.grid.YAxisTitleData +import com.netguru.multiplatform.charts.grid.axisscale.x.TimestampXAxisScale import com.netguru.multiplatform.charts.grid.drawChartGrid import com.netguru.multiplatform.charts.grid.measureChartGrid -import com.netguru.multiplatform.charts.theme.ChartColors import com.netguru.multiplatform.charts.theme.ChartTheme -val dashedPathEffect = PathEffect.dashPathEffect(floatArrayOf(5f, 5f), 0f) - /** - * Classic line chart with some shade below the line in the same color (albeit with a lot of - * transparency) as the line and floating balloon on touch/click to show values for that particular - * x-axis value. + * Classic line chart with some shade below the line in the same color as the line (albeit with a lot of transparency) + * and tooltip on touch/click to show values for that particular x-axis value. * - * Color, shape and whether the line is dashed for each of the lines is specified in the - * [LegendItemData] class. Even though that this class is used, this particular composable does not - * show the legend. For this, [LineChartWithLegend] must be used. + * Color, shape and whether the line is dashed for each of the lines is specified in the [LegendItemData] instance + * inside [LineChartData]. * - * @param lineChartData Data to portray - * @param colors Colors used are [ChartColors.grid], [ChartColors.surface] and - * [ChartColors.overlayLine]. - * @param xAxisLabel Composable to mark the values on the x-axis. - * @param yAxisLabel Composable to mark the values on the y-axis. - * @param overlayHeaderLabel Composable to show the current x-axis value on the overlay balloon - * @param overlayDataEntryLabel Composable to show the value of each line in the overlay balloon - * for that specific x-axis value - * @param animation Animation to use - * @param maxVerticalLines Max number of lines, representing the x-axis values - * @param maxHorizontalLines Max number of lines, representing the y-axis values - * @param roundMinMaxClosestTo Number to which min and max range will be rounded to + * @param data Data to display + * @param modifier Compose modifier + * @param yAxisConfig Configuration for the Y axis + * @param xAxisConfig Configuration for the X axis. If null, X axis is not displayed + * @param legendConfig Config for the legend. If null, legend is not displayed + * @param colors Colors used for grid, background, tooltip line color and tooltip background color + * @param tooltipConfig Configuration for the tooltip. If null, tooltip is not shown + * @param displayAnimation Animation to use to show the lines + * @param shouldDrawValueDots Whether there should be a dot on the chart line for each non-null Y value + * @param shouldInterpolateOverNullValues Whether chart line should be interpolated between two non-null Y values if + * there is at least one null Y value between them. Setting to false interrupts the line and starts drawing at the next + * non-null value */ -@OptIn(ExperimentalComposeUiApi::class) @Composable fun LineChart( - lineChartData: LineChartData, + data: LineChartData, modifier: Modifier = Modifier, - colors: LineChartColors = ChartTheme.colors.lineChartColors, - xAxisLabel: @Composable (value: Any) -> Unit = GridDefaults.XAxisLabel, - yAxisLabel: @Composable (value: Any) -> Unit = GridDefaults.YAxisLabel, - overlayHeaderLabel: @Composable (value: Any) -> Unit = GridDefaults.OverlayHeaderLabel, - overlayDataEntryLabel: @Composable (dataName: String, value: Any) -> Unit = GridDefaults.OverlayDataEntryLabel, - animation: ChartAnimation = ChartAnimation.Simple(), - maxVerticalLines: Int = GridDefaults.NUMBER_OF_GRID_LINES, - maxHorizontalLines: Int = GridDefaults.NUMBER_OF_GRID_LINES, - roundMinMaxClosestTo: Int = GridDefaults.ROUND_MIN_MAX_CLOSEST_TO, + yAxisConfig: YAxisConfig = ChartGridDefaults.yAxisConfig(data), + xAxisConfig: XAxisConfig? = XAxisConfig(), + legendConfig: LegendConfig? = LegendConfig(), + colors: LineChartColors = LineChartDefaults.lineChartColors(), + tooltipConfig: TooltipConfig? = TooltipConfig(), + displayAnimation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), + shouldDrawValueDots: Boolean = false, + shouldInterpolateOverNullValues: Boolean = true, ) { var touchPositionX by remember { mutableStateOf(-1f) } var verticalGridLines by remember { mutableStateOf(emptyList()) } var horizontalGridLines by remember { mutableStateOf(emptyList()) } - val horizontalLinesOffset: Dp = GridDefaults.HORIZONTAL_LINES_OFFSET - val animationPlayed = StartAnimation(animation, lineChartData) + val alpha = getAnimationAlphas(displayAnimation, data.series.size, data) - val alpha = when (animation) { - ChartAnimation.Disabled -> lineChartData.series.indices.map { 1f } - is ChartAnimation.Simple -> lineChartData.series.indices.map { - animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = animation.animationSpec() - ).value - } - is ChartAnimation.Sequenced -> lineChartData.series.indices.map { - animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = animation.animationSpec(it) - ).value + Column( + modifier = modifier + ) { + if (yAxisConfig.yAxisTitleData?.labelPosition == YAxisTitleData.LabelPosition.Top) { + yAxisConfig.yAxisTitleData.labelLayout() } - } - - Row(modifier = modifier) { - YAxisLabels( - horizontalGridLines = horizontalGridLines, - yAxisMarkerLayout = yAxisLabel, - ) - - Spacer(modifier = Modifier.size(4.dp, 0.dp)) + Row(modifier = Modifier.weight(1f)) { + if (yAxisConfig.markerLayout != null) { + YAxisLabels( + horizontalGridLines = horizontalGridLines, + yAxisMarkerLayout = yAxisConfig.markerLayout, + yAxisTitleData = yAxisConfig.yAxisTitleData, + modifier = Modifier + .padding(end = 8.dp) + ) + } - // main chart - Column(Modifier.fillMaxSize()) { - BoxWithConstraints( - Modifier - .fillMaxWidth() - .weight(1f) - .drawBehind { - val lines = measureChartGrid( - xAxisScale = TimestampXAxisScale( - min = lineChartData.minX, - max = lineChartData.maxX, - maxTicksCount = maxVerticalLines - 1 + val numberOfXAxisEntries by remember(data) { + derivedStateOf { + data + .series + .map { + it.listOfPoints + } + .maxOf { + it.size + } + } + } - ), - yAxisScale = YAxisScale( - min = lineChartData.minY, - max = lineChartData.maxY, - maxTickCount = maxHorizontalLines - 1, - roundClosestTo = roundMinMaxClosestTo, - ), - horizontalLinesOffset = horizontalLinesOffset + // main chart + Column(Modifier.fillMaxSize()) { + var pointsToDraw: List by remember { + mutableStateOf(emptyList()) + } + val xAxisScale = TimestampXAxisScale( + min = data.minX, + max = data.maxX, + maxTicksCount = ( + minOf( + xAxisConfig?.maxVerticalLines ?: ChartGridDefaults.NUMBER_OF_GRID_LINES, + numberOfXAxisEntries + ) - 1 ) - verticalGridLines = lines.verticalLines - horizontalGridLines = lines.horizontalLines - drawChartGrid(lines, colors.grid) + .coerceAtLeast(1), + roundClosestTo = xAxisConfig?.roundMarkersToMultiplicationOf + ?: ChartGridDefaults.ROUND_X_AXIS_MARKERS_CLOSEST_TO, + ) + BoxWithConstraints( + modifier = Modifier + .background(colors.surface) + .fillMaxWidth() + .weight(1f) + .drawBehind { + val lines = measureChartGrid( + xAxisScale = xAxisScale, + yAxisScale = yAxisConfig.scale, + ) + verticalGridLines = lines.verticalLines + horizontalGridLines = lines.horizontalLines + drawChartGrid( + grid = lines, + color = colors.grid, + ) - drawLineChart( - lineChartData = lineChartData, - graphTopPadding = horizontalLinesOffset, - graphBottomPadding = horizontalLinesOffset, - alpha = alpha, - ) - } - // Touch input - .pointerInput(Unit) { - while (true) { - awaitPointerEventScope { - val event = awaitPointerEvent(pass = PointerEventPass.Initial) + drawLineChart( + xAxisScale = xAxisScale, + yAxisScale = yAxisConfig.scale, + lineChartData = data, + alpha = alpha, + drawDots = shouldDrawValueDots, + selectedPointsForDrawing = pointsToDraw, + shouldInterpolateOverNullValues = shouldInterpolateOverNullValues, + ) + } + .then( + if (tooltipConfig != null) { + // Touch input + Modifier + .pointerInput(Unit) { + while (true) { + awaitPointerEventScope { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) - touchPositionX = if ( - shouldIgnoreTouchInput( - event = event, - containerSize = size - ) - ) { - -1f - } else { - event.changes[0].position.x - } + touchPositionX = if ( + shouldIgnoreTouchInput( + event = event + ) + ) { + -1f + } else { + event.changes[0].position.x + } - event.changes.any { - it.consume() - true - } + event.changes.any { + it.consume() + true + } + } + } + } + .pointerInput(Unit) { + while (true) { + awaitPointerEventScope { + val event = awaitPointerEvent(PointerEventPass.Main) + if ( + event.changes.all { it.changedToUp() } || + event.changes.any { + it.isOutOfBounds(size, extendedTouchPadding) + } + ) { + touchPositionX = -1f + } + } + } + } + } else { + Modifier } + ), + content = { + if (tooltipConfig != null) { + LineChartTooltip( + lineChartData = listOf(data), + positionX = touchPositionX, + containerSize = with(LocalDensity.current) { + Size( + maxWidth.toPx(), + maxHeight.toPx() + ) + }, + colors = colors, + drawPoints = { + pointsToDraw = it + }, + tooltipConfig = tooltipConfig, + xAxisScale = xAxisScale, + ) } } - ) { - // Overlay - LineChartOverlayInformation( - lineChartData = lineChartData, - positionX = touchPositionX, - containerSize = with(LocalDensity.current) { - Size( - maxWidth.toPx(), - maxHeight.toPx() - ) - }, - colors = colors, - overlayHeaderLayout = overlayHeaderLabel, - overlayDataEntryLayout = overlayDataEntryLabel, ) - } - Box(Modifier.fillMaxWidth()) { - for (gridLine in verticalGridLines) { - Box( - modifier = Modifier - .alignCenterToOffsetHorizontal(gridLine.position) - ) { - xAxisLabel(gridLine.value.toLong()) + if (xAxisConfig != null) { + Box(Modifier.fillMaxWidth()) { + DrawXAxisMarkers( + lineParams = verticalGridLines, + xAxisConfig = xAxisConfig, + modifier = Modifier + .fillMaxWidth() + ) } } } } + + if (legendConfig != null) { + ChartLegend( + legendData = data.legendData, + animation = displayAnimation, + legendItemLabel = legendConfig.legendItemLabel, + columnMinWidth = legendConfig.columnMinWidth, + ) + } } } -private fun shouldIgnoreTouchInput(event: PointerEvent, containerSize: IntSize): Boolean { - if (event.changes.isEmpty() || - event.type != PointerEventType.Move - ) { - return true - } - if (event.changes[0].position.x < 0 || - event.changes[0].position.x > containerSize.width - ) { - return true - } - if (event.changes[0].position.y < 0 || - event.changes[0].position.y > containerSize.height +private fun shouldIgnoreTouchInput(event: PointerEvent): Boolean { + if ( + event.changes.isEmpty() || + (event.type != PointerEventType.Move && event.type != PointerEventType.Press) ) { return true } + return false } + +object LineChartDefaults { + @Composable + fun lineChartColors( + backgroundColor: Color = ChartTheme.colors.surface, + gridColor: Color = ChartTheme.colors.grid, + overlayLineColor: Color = ChartTheme.colors.overlayLine, + overlayBackgroundColor: Color = ChartTheme.colors.surface, + ): LineChartColors = LineChartColors( + surface = backgroundColor, + grid = gridColor, + overlayLine = overlayLineColor, + overlaySurface = overlayBackgroundColor, + ) +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartColors.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartColors.kt index 1f82097..aae98da 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartColors.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartColors.kt @@ -1,19 +1,10 @@ package com.netguru.multiplatform.charts.line -import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color -import com.netguru.multiplatform.charts.theme.ChartColors -@Immutable data class LineChartColors( val grid: Color, val surface: Color, val overlayLine: Color, + val overlaySurface: Color = surface, ) - -val ChartColors.lineChartColors - get() = LineChartColors( - grid = grid, - surface = surface, - overlayLine = overlayLine, - ) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartOverlayInformation.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartOverlayInformation.kt index ddce930..03b706f 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartOverlayInformation.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartOverlayInformation.kt @@ -1,6 +1,7 @@ package com.netguru.multiplatform.charts.line import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,71 +13,294 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.netguru.multiplatform.charts.OverlayInformation +import com.netguru.multiplatform.charts.grid.axisscale.x.XAxisScale import com.netguru.multiplatform.charts.mapValueToDifferentRange +import kotlin.math.abs @Composable -internal fun LineChartOverlayInformation( - lineChartData: LineChartData, +internal fun LineChartTooltip( + lineChartData: List, positionX: Float, containerSize: Size, colors: LineChartColors, - overlayHeaderLayout: @Composable (value: Long) -> Unit, - overlayDataEntryLayout: @Composable (dataName: String, value: Float) -> Unit, + drawPoints: (points: List) -> Unit, + tooltipConfig: TooltipConfig, + xAxisScale: XAxisScale, ) { - if (positionX < 0) return - - OverlayInformation( - positionX = positionX, - containerSize = containerSize, - surfaceColor = colors.surface - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - val timestampCursor = getTimestampFromCursor( - xCursorPosition = positionX, - lineChartData = lineChartData, - containerSize = containerSize + if (tooltipConfig.showInterpolatedValues) { + LineChartOverlayInformationWithInterpolatedValues( + lineChartData = lineChartData, + positionX = positionX, + containerSize = containerSize, + colors = colors, + overlayHeaderLayout = tooltipConfig.headerLabel, + overlayDataEntryLayout = tooltipConfig.dataEntryLabel, + touchOffsetHorizontal = tooltipConfig.touchOffsetHorizontal, + touchOffsetVertical = tooltipConfig.touchOffsetVertical, + overlayWidth = tooltipConfig.width, + overlayAlpha = tooltipConfig.alpha, + xAxisScale = xAxisScale, + ) + } else { + LineChartTooltip( + lineChartData = lineChartData, + positionX = positionX, + containerSize = containerSize, + colors = colors, + overlayHeaderLayout = tooltipConfig.headerLabel, + overlayDataEntryLayout = tooltipConfig.dataEntryLabel, + drawPoints = drawPoints, + highlightPointsCloserThan = tooltipConfig.highlightPointsCloserThan, + touchOffsetHorizontal = tooltipConfig.touchOffsetHorizontal, + touchOffsetVertical = tooltipConfig.touchOffsetVertical, + overlayWidth = tooltipConfig.width, + overlayAlpha = tooltipConfig.alpha, + xAxisScale = xAxisScale, + ) + } +} + +@Composable +private fun LineChartTooltip( + lineChartData: List, + xAxisScale: XAxisScale, + positionX: Float, + containerSize: Size, + colors: LineChartColors, + overlayHeaderLayout: @Composable (value: Any, dataUnit: String?) -> Unit, + overlayDataEntryLayout: @Composable (dataName: String, dataNameShort: String?, dataUnit: String?, value: Any) -> Unit, + drawPoints: (points: List) -> Unit, + highlightPointsCloserThan: Dp, + touchOffsetHorizontal: Dp, + touchOffsetVertical: Dp, + overlayWidth: Dp?, + overlayAlpha: Float, +) { + if (positionX < 0) { + drawPoints(emptyList()) + return + } + + val timestampCursor = getTimestampFromCursor( + xCursorPosition = positionX, + xAxisScale = xAxisScale, + containerSize = containerSize + ) + val listOfValues = retrieveDataWithClosestPointForEachSeries(lineChartData, timestampCursor) + var linePositionX: Float? by remember { + mutableStateOf(null) + } + + if (listOfValues.isNotEmpty()) { + + val highlightPointsCloserThanPixels = with(LocalDensity.current) { + highlightPointsCloserThan.toPx() + } + + linePositionX = + listOfValues.first().closestPoint.x.mapValueToDifferentRange( + inMin = xAxisScale.start, + inMax = xAxisScale.end, + outMin = 0f, + outMax = containerSize.width, ) - val listOfValues = retrieveData(lineChartData, timestampCursor) - - overlayHeaderLayout(timestampCursor) - - Spacer(modifier = Modifier.height(4.dp)) - - listOfValues.forEach { seriesAndInterpolatedValue -> - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - modifier = Modifier - .size(16.dp, 4.dp) - .drawBehind { - drawLine( - strokeWidth = seriesAndInterpolatedValue.lineChartSeries.lineWidth.toPx(), - pathEffect = if (seriesAndInterpolatedValue.lineChartSeries.dashedLine) - dashedPathEffect else null, - color = seriesAndInterpolatedValue.lineChartSeries.lineColor, - start = Offset(0f, size.height / 2), - end = Offset(size.width, size.height / 2) - ) - } - ) - Spacer(modifier = Modifier.width(8.dp)) + val (pointsToAvoid, valuesToShowDataFor) = listOfValues.mapNotNull { + val (x, y) = it.closestPoint.let { point -> + point.x.mapValueToDifferentRange( + inMin = xAxisScale.start, + inMax = xAxisScale.end, + outMin = 0f, + outMax = containerSize.width, + ) to point.y!!.mapValueToDifferentRange( + inMin = it.lineChartSeries.minValue, + inMax = it.lineChartSeries.maxValue, + outMin = 0f, + outMax = containerSize.height, + ) + } - val dataName = seriesAndInterpolatedValue.lineChartSeries.dataName - val interpolatedValue = seriesAndInterpolatedValue.interpolatedValue - overlayDataEntryLayout(dataName, interpolatedValue) - } + if (abs(x - linePositionX!!) <= highlightPointsCloserThanPixels) { + Offset(x, y) to it + } else { + null } } + .unzip() + + drawPoints(valuesToShowDataFor) + + OverlayInformation( + positionX = linePositionX, + containerSize = containerSize, + backgroundColor = colors.overlaySurface, + pointsToAvoid = pointsToAvoid, + touchOffsetHorizontal = touchOffsetHorizontal, + touchOffsetVertical = touchOffsetVertical, + requiredOverlayWidth = overlayWidth, + overlayAlpha = overlayAlpha, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + valuesToShowDataFor + .groupBy { it.closestPoint.x } + .forEach { closestPointBySeries -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + overlayHeaderLayout(closestPointBySeries.key, null) + + Spacer(modifier = Modifier.height(4.dp)) + + closestPointBySeries.value + .forEach { seriesAndClosestPoint -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.Start) + ) { + Box( + modifier = Modifier + .size(16.dp, 4.dp) + .drawBehind { + drawLine( + strokeWidth = seriesAndClosestPoint.lineChartSeries.lineWidth.toPx(), + pathEffect = seriesAndClosestPoint.lineChartSeries.pathEffect, + color = seriesAndClosestPoint.lineChartSeries.lineColor, + start = Offset(0f, size.height / 2), + end = Offset(size.width, size.height / 2) + ) + } + ) + + Spacer(modifier = Modifier.width(8.dp)) + + val dataName = seriesAndClosestPoint.lineChartSeries.dataName + val dataNameShort = seriesAndClosestPoint.lineChartSeries.dataNameShort + val dataUnit = seriesAndClosestPoint.dataUnit + val value = seriesAndClosestPoint.closestPoint.y + overlayDataEntryLayout(dataName, dataNameShort, dataUnit, value!!) + } + } + } + } + } + } + } + + // Vertical marker line + linePositionX?.let { + Spacer( + Modifier + .offset( + with(LocalDensity.current) { it.toDp() }, 0.dp + ) + .width(1.dp) + .fillMaxHeight() + .background(colors.overlayLine) + ) + } +} + +@Composable +private fun LineChartOverlayInformationWithInterpolatedValues( + lineChartData: List, + xAxisScale: XAxisScale, + positionX: Float, + containerSize: Size, + colors: LineChartColors, + overlayHeaderLayout: @Composable (value: Any, dataUnit: String?) -> Unit, + overlayDataEntryLayout: @Composable (dataName: String, dataNameShort: String?, dataUnit: String?, value: Any) -> Unit, + touchOffsetHorizontal: Dp, + touchOffsetVertical: Dp, + overlayWidth: Dp?, + overlayAlpha: Float, +) { + if (positionX < 0) { + return + } + + val combinedLineChartData by remember(lineChartData) { + derivedStateOf { + LineChartData( + series = lineChartData.flatMap { it.series }, + dataUnit = null, + ) + } + } + + val timestampCursor = getTimestampFromCursor( + xCursorPosition = positionX, + xAxisScale = xAxisScale, + containerSize = containerSize + ) + val listOfValues = retrieveDataWithInterpolatedValue(combinedLineChartData, timestampCursor) + + if (listOfValues.isNotEmpty()) { + OverlayInformation( + positionX = positionX, + containerSize = containerSize, + backgroundColor = colors.overlaySurface, + touchOffsetHorizontal = touchOffsetHorizontal, + touchOffsetVertical = touchOffsetVertical, + requiredOverlayWidth = overlayWidth, + overlayAlpha = overlayAlpha, + content = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val dataUnit = listOfValues.first().lineChartSeries.dataName // todo + val dataNameShort = listOfValues.first().lineChartSeries.dataNameShort + overlayHeaderLayout(timestampCursor, dataUnit) + + Spacer(modifier = Modifier.height(4.dp)) + + listOfValues.forEach { seriesAndInterpolatedValue -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.Start) + ) { + Box( + modifier = Modifier + .size(16.dp, 4.dp) + .drawBehind { + drawLine( + strokeWidth = seriesAndInterpolatedValue.lineChartSeries.lineWidth.toPx(), + pathEffect = seriesAndInterpolatedValue.lineChartSeries.pathEffect, + color = seriesAndInterpolatedValue.lineChartSeries.lineColor, + start = Offset(0f, size.height / 2), + end = Offset(size.width, size.height / 2) + ) + } + ) + + Spacer(modifier = Modifier.width(8.dp)) + + val dataName = seriesAndInterpolatedValue.lineChartSeries.dataName + val interpolatedValue = seriesAndInterpolatedValue.interpolatedValue + overlayDataEntryLayout(dataName, dataNameShort, dataUnit, interpolatedValue) + } + } + } + }, + ) } // Vertical marker line @@ -93,17 +317,40 @@ internal fun LineChartOverlayInformation( private fun getTimestampFromCursor( xCursorPosition: Float, - lineChartData: LineChartData, containerSize: Size, + xAxisScale: XAxisScale, ) = xCursorPosition.toLong().mapValueToDifferentRange( 0L, containerSize.width.toLong(), - lineChartData.minX, - lineChartData.maxX + xAxisScale.start, + xAxisScale.end, ) -private fun retrieveData( +private fun retrieveDataWithClosestPointForEachSeries( + lineChartDataList: List, + timestampCursor: Long, +): List { + // find time value from position of the cursor + + val outputList: MutableList = mutableListOf() + lineChartDataList.forEach { lineChartData -> + lineChartData.series.forEach { series -> + // find the closest point + series.listOfPoints + .filter { it.y != null } + .minByOrNull { abs(it.x - timestampCursor) } + ?.let { + outputList.add( + SeriesAndClosestPoint(series, it, lineChartData.dataUnit) + ) + } + } + } + return outputList.sortedBy { abs(it.closestPoint.x - timestampCursor) } +} + +private fun retrieveDataWithInterpolatedValue( lineChartData: LineChartData, timestampCursor: Long, ): List { @@ -119,7 +366,7 @@ private fun retrieveData( .filter { it.x <= timestampCursor } .maxByOrNull { it.x } - if (v0 != null && v1 != null) { + if (v0?.y != null && v1?.y != null) { val interpolatedValue = interpolateBetweenValues( v0.y, @@ -151,3 +398,10 @@ private data class SeriesAndInterpolatedValue( val lineChartSeries: LineChartSeries, val interpolatedValue: Float, ) + +@Immutable +data class SeriesAndClosestPoint( + val lineChartSeries: LineChartSeries, + val closestPoint: LineChartPoint, + val dataUnit: String?, +) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartSeries.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartSeries.kt index 639ac45..0e21b1e 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartSeries.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartSeries.kt @@ -2,6 +2,7 @@ package com.netguru.multiplatform.charts.line import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.netguru.multiplatform.charts.grid.GridChartData @@ -9,22 +10,23 @@ import com.netguru.multiplatform.charts.grid.GridChartData @Immutable data class LineChartPoint( val x: Long, - val y: Float, + val y: Float?, ) @Immutable data class LineChartSeries( val dataName: String, + val dataNameShort: String? = null, val lineWidth: Dp = 3.dp, val lineColor: Color, val fillColor: Color = lineColor, - val dashedLine: Boolean = false, + val pathEffect: PathEffect? = null, val listOfPoints: List = emptyList(), ) { val minValue: Float val maxValue: Float val minTimestamp: Long - val maxTimeStamp: Long + val maxTimestamp: Long init { // find max and min in series @@ -35,12 +37,12 @@ data class LineChartSeries( val minMaxTimestamp = getMinMaxTimestamp() minTimestamp = minMaxTimestamp.first - maxTimeStamp = minMaxTimestamp.second + maxTimestamp = minMaxTimestamp.second } else { minValue = 0f maxValue = 0f minTimestamp = 0L - maxTimeStamp = 0L + maxTimestamp = 0L } } @@ -50,22 +52,29 @@ data class LineChartSeries( } private fun getMinMaxValue(): Pair { - val sortedValue = listOfPoints.sortedBy { it.y } - return Pair(sortedValue.first().y, sortedValue.last().y) + return listOfPoints + .filter { it.y != null } + .sortedBy { it.y } + .takeIf { it.isNotEmpty() } + ?.let { + Pair(it.first().y!!, it.last().y!!) + } ?: Pair(0f, 0f) } } @Immutable data class LineChartData( val series: List, + val dataUnit: String?, ) : GridChartData { override val legendData: List get() = series.map { LegendItemData( name = it.dataName, + unit = dataUnit, symbolShape = SymbolShape.LINE, color = it.lineColor, - dashed = it.dashedLine + pathEffect = it.pathEffect, ) } @@ -78,12 +87,17 @@ data class LineChartData( // find max and min in all data val timeStamps = mutableListOf() val values = mutableListOf() - series.forEach { - timeStamps.add(it.minTimestamp) - timeStamps.add(it.maxTimeStamp) - values.add(it.minValue) - values.add(it.maxValue) - } + series + .forEach { + if (it.listOfPoints.any { point -> point.y != null }) { + // null-only series, that are used to make timestamp ranges compatible between different series, + // must not be used for y-axis values, since they always report min=0f, which breaks the chart. + values.add(it.minValue) + values.add(it.maxValue) + } + timeStamps.add(it.minTimestamp) + timeStamps.add(it.maxTimestamp) + } minX = timeStamps.minOrNull() ?: 0 maxX = timeStamps.maxOrNull() ?: 0 minY = values.minOrNull() ?: 0f diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartWithLegend.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartWithLegend.kt deleted file mode 100644 index e84eb51..0000000 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartWithLegend.kt +++ /dev/null @@ -1,53 +0,0 @@ -package com.netguru.multiplatform.charts.line - -import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.netguru.multiplatform.charts.ChartAnimation -import com.netguru.multiplatform.charts.grid.GridDefaults -import com.netguru.multiplatform.charts.theme.ChartTheme - -/** - * Classic line chart with legend below the chart. - * - * For more information, check [LineChart]. - * - * @param legendItemLabel Composable to use to represent the value in the legend. Only text value - * is customizable. - * - * @see LineChart - */ -@Composable -fun LineChartWithLegend( - lineChartData: LineChartData, - modifier: Modifier = Modifier, - maxVerticalLines: Int = GridDefaults.NUMBER_OF_GRID_LINES, - maxHorizontalLines: Int = GridDefaults.NUMBER_OF_GRID_LINES, - animation: ChartAnimation = ChartAnimation.Simple(), - colors: LineChartColors = ChartTheme.colors.lineChartColors, - xAxisLabel: @Composable (value: Any) -> Unit = GridDefaults.XAxisLabel, - yAxisLabel: @Composable (value: Any) -> Unit = GridDefaults.YAxisLabel, - overlayHeaderLabel: @Composable (value: Any) -> Unit = GridDefaults.OverlayHeaderLabel, - overlayDataEntryLabel: @Composable (dataName: String, value: Any) -> Unit = GridDefaults.OverlayDataEntryLabel, - legendItemLabel: @Composable (String) -> Unit = GridDefaults.LegendItemLabel, -) { - Column(modifier = modifier) { - LineChart( - lineChartData = lineChartData, - modifier = Modifier.weight(1f), - maxVerticalLines = maxVerticalLines, - maxHorizontalLines = maxHorizontalLines, - animation = animation, - colors = colors, - xAxisLabel = xAxisLabel, - yAxisLabel = yAxisLabel, - overlayHeaderLabel = overlayHeaderLabel, - overlayDataEntryLabel = overlayDataEntryLabel, - ) - ChartLegend( - legendData = lineChartData.legendData, - animation = animation, - legendItemLabel = legendItemLabel, - ) - } -} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartWithTwoYAxisSets.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartWithTwoYAxisSets.kt new file mode 100644 index 0000000..c70ba58 --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/LineChartWithTwoYAxisSets.kt @@ -0,0 +1,400 @@ +package com.netguru.multiplatform.charts.line + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.getAnimationAlphas +import com.netguru.multiplatform.charts.grid.ChartGridDefaults +import com.netguru.multiplatform.charts.grid.DrawXAxisMarkers +import com.netguru.multiplatform.charts.grid.LineParameters +import com.netguru.multiplatform.charts.grid.YAxisLabels +import com.netguru.multiplatform.charts.grid.YAxisTitleData +import com.netguru.multiplatform.charts.grid.axisscale.x.TimestampXAxisScale +import com.netguru.multiplatform.charts.grid.axisscale.y.YAxisScaleDynamic +import com.netguru.multiplatform.charts.grid.axisscale.y.YAxisScaleStatic +import com.netguru.multiplatform.charts.grid.drawChartGrid +import com.netguru.multiplatform.charts.grid.measureChartGrid + +/** + * Classic line chart with some shade below the line in the same color as the line (albeit with a lot of transparency) + * and tooltip on touch/click to show values for that particular x-axis value but with the option to show two different + * Y-axis scales (one on each side of the chart). + * + * Color, shape and whether the line is dashed for each of the lines is specified in the [LegendItemData] instance + * inside each [LineChartData]. + * + * @param leftYAxisData Data to display with scale on the left side + * @param rightYAxisData Data to display with scale on the right side. If null, classic [LineChart] is used. + * @param modifier Compose modifier + * @param leftYAxisConfig Configuration for the left Y axis + * @param rightYAxisConfig Configuration for the right Y axis + * @param xAxisConfig Configuration for the X axis. If null, X axis is not displayed + * @param legendConfig Config for the legend. If null, legend is not displayed + * @param colors Colors used for grid, background, tooltip line color and tooltip background color + * @param tooltipConfig Configuration for the tooltip. If null, tooltip is not shown + * @param displayAnimation Animation to use to show the lines + * @param shouldDrawValueDots Whether there should be a dot on the chart line for each non-null Y value + * @param shouldInterpolateOverNullValues Whether chart line should be interpolated between two non-null Y values if + * there is at least one null Y value between them. Setting to false interrupts the line and starts drawing at the next + * non-null value + */ +@Composable +fun LineChartWithTwoYAxisSets( + leftYAxisData: LineChartData, + rightYAxisData: LineChartData?, + modifier: Modifier = Modifier, + leftYAxisConfig: YAxisConfig = ChartGridDefaults.yAxisConfig(leftYAxisData), + rightYAxisConfig: YAxisConfig? = rightYAxisData?.let { ChartGridDefaults.yAxisConfig(it) }, + xAxisConfig: XAxisConfig? = XAxisConfig(), + legendConfig: LegendConfig? = LegendConfig(), + colors: LineChartColors = LineChartDefaults.lineChartColors(), + tooltipConfig: TooltipConfig? = TooltipConfig(), + displayAnimation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), + shouldDrawValueDots: Boolean = false, + shouldInterpolateOverNullValues: Boolean = true, +) { + if (rightYAxisData != null) { + LineChartWithTwoYAxisSetsLayout( + leftYAxisData = leftYAxisData, + rightYAxisData = rightYAxisData, + modifier = modifier, + leftYAxisConfig = leftYAxisConfig, + rightYAxisConfig = rightYAxisConfig, + xAxisConfig = xAxisConfig, + legendConfig = legendConfig, + colors = colors, + tooltipConfig = tooltipConfig, + displayAnimation = displayAnimation, + shouldDrawValueDots = shouldDrawValueDots, + shouldInterpolateOverNullValues = shouldInterpolateOverNullValues, + ) + } else { + LineChart( + data = leftYAxisData, + modifier = modifier, + yAxisConfig = leftYAxisConfig, + xAxisConfig = xAxisConfig, + legendConfig = legendConfig, + colors = colors, + tooltipConfig = tooltipConfig, + displayAnimation = displayAnimation, + shouldDrawValueDots = shouldDrawValueDots, + shouldInterpolateOverNullValues = shouldInterpolateOverNullValues, + ) + } +} + +@Composable +private fun LineChartWithTwoYAxisSetsLayout( + leftYAxisData: LineChartData, + rightYAxisData: LineChartData, + modifier: Modifier, + leftYAxisConfig: YAxisConfig, + rightYAxisConfig: YAxisConfig?, + xAxisConfig: XAxisConfig?, + legendConfig: LegendConfig?, + colors: LineChartColors, + tooltipConfig: TooltipConfig?, + displayAnimation: ChartDisplayAnimation, + shouldDrawValueDots: Boolean, + shouldInterpolateOverNullValues: Boolean, +) { + var touchPositionX by remember { mutableStateOf(-1f) } + var verticalGridLines by remember { mutableStateOf(emptyList()) } + var leftYAxisMarks by remember { mutableStateOf(emptyList()) } + var rightYAxisMarks by remember { mutableStateOf(emptyList()) } + + val alphas = getAnimationAlphas( + animation = displayAnimation, + numberOfElementsToAnimate = leftYAxisData.series.size + rightYAxisData.series.size, + uniqueDatasetKey = LineChartData( + series = leftYAxisData.series + rightYAxisData.series, + dataUnit = null, + ), + ) + + fun LineChartData.addNoYValuePointsFrom(another: LineChartData): LineChartData { + val anotherSeries = another.series + .map { it.copy(listOfPoints = it.listOfPoints.map { point -> point.copy(y = null) }) } + + return copy(series = series + anotherSeries) + } + + Column( + modifier = modifier, + ) { + if (leftYAxisConfig.yAxisTitleData?.labelPosition == YAxisTitleData.LabelPosition.Top || + rightYAxisConfig?.yAxisTitleData?.labelPosition == YAxisTitleData.LabelPosition.Top + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, + modifier = Modifier + .fillMaxWidth() + ) { + leftYAxisConfig.yAxisTitleData?.labelLayout?.invoke() ?: Spacer(Modifier.size(1.dp)) + rightYAxisConfig?.yAxisTitleData?.labelLayout?.invoke() ?: Spacer(Modifier.size(1.dp)) + } + } + Row(modifier = Modifier.weight(1f)) { + if (leftYAxisConfig.markerLayout != null) { + YAxisLabels( + horizontalGridLines = leftYAxisMarks, + yAxisMarkerLayout = leftYAxisConfig.markerLayout, + yAxisTitleData = leftYAxisConfig.yAxisTitleData, + modifier = Modifier + .padding(end = 8.dp) + ) + } + + val numberOfXAxisEntries by remember(leftYAxisData, rightYAxisData) { + derivedStateOf { + (leftYAxisData.series + + rightYAxisData.series + ) + .map { + it.listOfPoints + } + .maxOf { + it.size + } + } + } + + // main chart + Column(Modifier.weight(1f)) { + var pointsToDraw: List by remember { + mutableStateOf(emptyList()) + } + val xAxisScale = TimestampXAxisScale( + min = minOf(leftYAxisData.minX, rightYAxisData.minX), + max = maxOf(leftYAxisData.maxX, rightYAxisData.maxX), + maxTicksCount = ( + minOf( + xAxisConfig?.maxVerticalLines ?: ChartGridDefaults.NUMBER_OF_GRID_LINES, + numberOfXAxisEntries + ) - 1 + ) + .coerceAtLeast(1), + roundClosestTo = xAxisConfig?.roundMarkersToMultiplicationOf + ) + BoxWithConstraints( + Modifier + .fillMaxWidth() + .weight(1f) + .drawBehind { + val lines = measureChartGrid( + xAxisScale = xAxisScale, + yAxisScale = YAxisScaleStatic( + min = minOf(leftYAxisData.minY, rightYAxisData.minY), + max = maxOf(leftYAxisData.maxY, rightYAxisData.maxY), + numberOfHorizontalLines = minOf( + leftYAxisConfig.scale.numberOfHorizontalLines, + rightYAxisConfig?.scale?.numberOfHorizontalLines ?: Int.MAX_VALUE + ) + ), + ).also { + verticalGridLines = it.verticalLines + } + + if (leftYAxisConfig.markerLayout != null) { + leftYAxisMarks = measureChartGrid( + xAxisScale = TimestampXAxisScale( + min = 0, + max = 0, + roundClosestTo = xAxisConfig?.roundMarkersToMultiplicationOf, + ), + yAxisScale = leftYAxisConfig.scale + ) + .horizontalLines +// .let { +// val containsZeroValue = +// it.firstOrNull { line -> line.position == lines.zeroPosition.position } != null +// if (containsZeroValue) { +// it +// } else { +// it + lines.zeroPosition +// } +// } + } + if (rightYAxisConfig?.markerLayout != null) { + rightYAxisMarks = measureChartGrid( + xAxisScale = TimestampXAxisScale( + min = 0, + max = 0, + roundClosestTo = xAxisConfig?.roundMarkersToMultiplicationOf, + ), + yAxisScale = rightYAxisConfig.scale + ) + .horizontalLines +// .let { +// val containsZeroValue = +// it.firstOrNull { line -> line.position == lines.zeroPosition.position } != null +// if (containsZeroValue) { +// it +// } else { +// it + lines.zeroPosition +// } +// } + } + + drawChartGrid(lines, colors.grid) + + drawLineChart( + // we have to join those points so that the x-values align properly. Otherwise, in case when + // datasets would not start and end at the same x value, they would still be drawn from the + // same start and end point, making (at least) one of them drawn incorrectly + lineChartData = leftYAxisData.addNoYValuePointsFrom(rightYAxisData), + alpha = alphas, + drawDots = shouldDrawValueDots, + selectedPointsForDrawing = pointsToDraw.filter { + leftYAxisData.series.contains( + it.lineChartSeries + ) + }, + xAxisScale = xAxisScale, + yAxisScale = leftYAxisConfig.scale, + shouldInterpolateOverNullValues = shouldInterpolateOverNullValues, + ) + + drawLineChart( + // we have to join those points so that the x-values align properly. Otherwise, in case when + // datasets would not start and end at the same x value, they would still be drawn from the + // same start and end point, making (at least) one of them drawn incorrectly + lineChartData = rightYAxisData.addNoYValuePointsFrom(leftYAxisData), + alpha = alphas, + drawDots = shouldDrawValueDots, + selectedPointsForDrawing = pointsToDraw.filter { + rightYAxisData.series.contains( + it.lineChartSeries + ) + }, + xAxisScale = xAxisScale, + + yAxisScale = rightYAxisConfig?.scale ?: YAxisScaleDynamic(rightYAxisData), + shouldInterpolateOverNullValues = shouldInterpolateOverNullValues, + ) + } + // Touch input + .pointerInput(Unit) { + while (true) { + awaitPointerEventScope { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + + touchPositionX = if ( + shouldIgnoreTouchInput( + event = event, + containerSize = size + ) + ) { + -1f + } else { + event.changes[0].position.x + } + + event.changes.any { + it.consume() + true + } + } + } + }, + content = { + // Overlay + if (tooltipConfig != null) { + LineChartTooltip( + lineChartData = listOf(leftYAxisData, rightYAxisData), + positionX = touchPositionX, + containerSize = with(LocalDensity.current) { + Size( + maxWidth.toPx(), + maxHeight.toPx() + ) + }, + colors = colors, + drawPoints = { + pointsToDraw = it + }, + tooltipConfig = tooltipConfig, + xAxisScale = xAxisScale, + ) + } + } + ) + + if (xAxisConfig != null) { + Box(Modifier.fillMaxWidth()) { + DrawXAxisMarkers( + lineParams = verticalGridLines, + xAxisConfig = xAxisConfig, + modifier = Modifier + .fillMaxWidth() + ) + } + } + } + + if (rightYAxisConfig?.markerLayout != null) { + YAxisLabels( + horizontalGridLines = rightYAxisMarks, + yAxisMarkerLayout = rightYAxisConfig.markerLayout, + yAxisTitleData = rightYAxisConfig.yAxisTitleData, + modifier = Modifier + .padding(start = 8.dp) + ) + } + } + + if (legendConfig != null) { + ChartLegend( + legendData = leftYAxisData.legendData + rightYAxisData.legendData, + animation = displayAnimation, + legendItemLabel = legendConfig.legendItemLabel, + columnMinWidth = legendConfig.columnMinWidth, + ) + } + } +} + +private fun shouldIgnoreTouchInput(event: PointerEvent, containerSize: IntSize): Boolean { + if (event.changes.isEmpty() || + event.type != PointerEventType.Move + ) { + return true + } + if (event.changes[0].position.x < 0 || + event.changes[0].position.x > containerSize.width + ) { + return true + } + if (event.changes[0].position.y < 0 || + event.changes[0].position.y > containerSize.height + ) { + return true + } + return false +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/Progression.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/Progression.kt new file mode 100644 index 0000000..15351db --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/Progression.kt @@ -0,0 +1,13 @@ +package com.netguru.multiplatform.charts.line + +sealed class Progression { + object Linear : Progression() + class NonLinear( + val anchorPoints: List, + ) : Progression() { + data class AnchorPoint( + val value: Float, + val position: Float, + ) + } +} diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/TooltipConfig.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/TooltipConfig.kt new file mode 100644 index 0000000..833a308 --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/TooltipConfig.kt @@ -0,0 +1,18 @@ +package com.netguru.multiplatform.charts.line + +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.netguru.multiplatform.charts.grid.ChartGridDefaults + +data class TooltipConfig( + val headerLabel: @Composable (value: Any, dataUnit: String?) -> Unit = ChartGridDefaults.TooltipHeaderLabel, + val dataEntryLabel: @Composable (dataName: String, dataNameShort: String?, dataUnit: String?, value: Any) -> Unit = ChartGridDefaults.TooltipDataEntryLabel, + val showEnlargedPointOnLine: Boolean = false, + val showInterpolatedValues: Boolean = true, + val highlightPointsCloserThan: Dp = 30.dp, + val touchOffsetHorizontal: Dp = 20.dp, + val touchOffsetVertical: Dp = 20.dp, + val width: Dp? = 200.dp, + val alpha: Float = 0.9f, +) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/XAxisConfig.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/XAxisConfig.kt new file mode 100644 index 0000000..8b0a06c --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/XAxisConfig.kt @@ -0,0 +1,12 @@ +package com.netguru.multiplatform.charts.line + +import androidx.compose.runtime.Composable +import com.netguru.multiplatform.charts.grid.ChartGridDefaults + +data class XAxisConfig( + val markerLayout: @Composable (value: Any) -> Unit = ChartGridDefaults.XAxisMarkerLayout, + val hideMarkersWhenOverlapping: Boolean = false, + val alignFirstAndLastToChartEdges: Boolean = false, + val roundMarkersToMultiplicationOf: Long = ChartGridDefaults.ROUND_X_AXIS_MARKERS_CLOSEST_TO, + val maxVerticalLines: Int = ChartGridDefaults.NUMBER_OF_GRID_LINES, +) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/YAxisConfig.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/YAxisConfig.kt new file mode 100644 index 0000000..6512a8f --- /dev/null +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/line/YAxisConfig.kt @@ -0,0 +1,12 @@ +package com.netguru.multiplatform.charts.line + +import androidx.compose.runtime.Composable +import com.netguru.multiplatform.charts.grid.ChartGridDefaults +import com.netguru.multiplatform.charts.grid.YAxisTitleData +import com.netguru.multiplatform.charts.grid.axisscale.y.YAxisScale + +data class YAxisConfig( + val markerLayout: (@Composable (value: Any) -> Unit)? = ChartGridDefaults.YAxisMarkerLayout, + val yAxisTitleData: YAxisTitleData? = ChartGridDefaults.YAxisDataTitle, + val scale: YAxisScale, +) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChart.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChart.kt index 9aa1974..230035c 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChart.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChart.kt @@ -1,6 +1,5 @@ package com.netguru.multiplatform.charts.pie -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.runtime.Composable @@ -18,8 +17,8 @@ import androidx.compose.ui.graphics.PathOperation import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipPath -import com.netguru.multiplatform.charts.ChartAnimation -import com.netguru.multiplatform.charts.StartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.getAnimationAlphas import com.netguru.multiplatform.charts.mapValueToDifferentRange import com.netguru.multiplatform.charts.pie.PieDefaults.FULL_CIRCLE_DEGREES import com.netguru.multiplatform.charts.pie.PieDefaults.START_ANGLE @@ -37,34 +36,24 @@ data class PieChartData(val name: String, val value: Double, val color: Color) * [PieChartWithLegend] * * @param data Data to show - * @param animation Animation to use. [ChartAnimation.Sequenced] is currently not supported and will + * @param animation Animation to use. [ChartDisplayAnimation.Sequenced] is currently not supported and will * @param config The parameters for chart appearance customization * throw an [kotlin.UnsupportedOperationException] if used. * - * @throws kotlin.UnsupportedOperationException when [ChartAnimation.Sequenced] is used + * @throws kotlin.UnsupportedOperationException when [ChartDisplayAnimation.Sequenced] is used */ @Composable fun PieChart( data: List, modifier: Modifier = Modifier, - animation: ChartAnimation = ChartAnimation.Simple(), + animation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), config: PieChartConfig = PieChartConfig(), ) { - val animationPlayed = StartAnimation(animation, data) - val maxAngle = when (animation) { - ChartAnimation.Disabled -> { - 1f - } - is ChartAnimation.Simple -> { - animateFloatAsState( - targetValue = if (animationPlayed) FULL_CIRCLE_DEGREES else 0f, - animationSpec = animation.animationSpec() - ).value - } - is ChartAnimation.Sequenced -> { - throw UnsupportedOperationException("ChartAnimation.Sequenced is currently not supported for PieChart!") - } - } + val maxAngle = getAnimationAlphas( + animation = animation, + numberOfElementsToAnimate = 1, + uniqueDatasetKey = data, + ).first() * FULL_CIRCLE_DEGREES val sumOfData by remember(data) { mutableStateOf(data.sumOf { it.value }) diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChartLegend.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChartLegend.kt index 6a0c323..94ecc56 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChartLegend.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChartLegend.kt @@ -1,6 +1,5 @@ package com.netguru.multiplatform.charts.pie -import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -8,14 +7,9 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.GridCells -import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -25,7 +19,8 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.clipRect -import com.netguru.multiplatform.charts.ChartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.getAnimationAlphas import kotlin.random.Random @OptIn(ExperimentalFoundationApi::class) @@ -33,31 +28,16 @@ import kotlin.random.Random internal fun PieChartLegend( data: List, modifier: Modifier = Modifier, - animation: ChartAnimation = ChartAnimation.Simple(), + animation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), config: PieChartConfig = PieChartConfig(), legendItemLabel: @Composable (PieChartData) -> Unit = PieDefaults.LegendItemLabel, ) { - var animationPlayed by remember(data) { - mutableStateOf(animation is ChartAnimation.Disabled) - } - LaunchedEffect(Unit) { - animationPlayed = true - } - val animatedAlpha = when (animation) { - ChartAnimation.Disabled -> data.indices.map { 1f } - is ChartAnimation.Simple -> data.indices.map { - animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = animation.animationSpec() - ).value - } - is ChartAnimation.Sequenced -> data.indices.map { - animateFloatAsState( - targetValue = if (animationPlayed) 1f else 0f, - animationSpec = animation.animationSpec(it) - ).value - } - } + val animatedAlpha = getAnimationAlphas( + animation = animation, + numberOfElementsToAnimate = data.size, + uniqueDatasetKey = data, + ) + val columnsPerRow = when (config.legendOrientation) { LegendOrientation.HORIZONTAL -> config.numberOfColsInLegend LegendOrientation.VERTICAL -> 1 @@ -65,7 +45,7 @@ internal fun PieChartLegend( LazyVerticalGrid( horizontalArrangement = Arrangement.SpaceAround, verticalArrangement = Arrangement.SpaceAround, - cells = GridCells.Fixed(columnsPerRow), + columns = GridCells.Fixed(columnsPerRow), content = { items(data.count()) { index -> LegendItem( @@ -105,6 +85,7 @@ private fun LegendItem( ) legendItemLabel(pieChartData) } + LegendOrientation.VERTICAL -> Row( modifier = Modifier.alpha(alpha), @@ -134,15 +115,18 @@ private fun DrawScope.drawLegendIcon( LegendIcon.SQUARE -> drawRect( color = color, ) + LegendIcon.CIRCLE -> drawCircle( color = color ) + LegendIcon.ROUND -> drawRoundRect( color = color, cornerRadius = CornerRadius( config.legendIconSize.toPx() / 4f ) ) + LegendIcon.CAKE -> drawCircle( color = color, center = Offset(x = 0f, y = size.height), diff --git a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChartWithLegend.kt b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChartWithLegend.kt index 1aaebeb..0880c25 100644 --- a/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChartWithLegend.kt +++ b/charts/src/commonMain/kotlin/com/netguru/multiplatform/charts/pie/PieChartWithLegend.kt @@ -9,7 +9,7 @@ import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.netguru.multiplatform.charts.ChartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation /** * Version of [PieChart] with legend. @@ -22,7 +22,7 @@ import com.netguru.multiplatform.charts.ChartAnimation fun PieChartWithLegend( pieChartData: List, modifier: Modifier = Modifier, - animation: ChartAnimation = ChartAnimation.Simple(), + animation: ChartDisplayAnimation = ChartDisplayAnimation.Simple(), config: PieChartConfig = PieChartConfig(), legendItemLabel: @Composable (PieChartData) -> Unit = PieDefaults.LegendItemLabel, ) { diff --git a/example-app/android/build.gradle.kts b/example-app/android/build.gradle.kts index 7814f96..f95632e 100644 --- a/example-app/android/build.gradle.kts +++ b/example-app/android/build.gradle.kts @@ -44,5 +44,7 @@ android { } shot { - tolerance = 2.0 // 2% tolerance, needed for testing on different devices +// tolerance is needed when tests were recorded on a machine with Intel processor and executed on an Apple's M1 (or M2?) +// based machine (or vice versa). Here is more about it: https://github.com/pedrovgs/Shot/issues/265 +// tolerance = 0.5 // 0.5% } diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value0_joinJoined_roundCorners_customThickness.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value0_joinJoined_roundCorners_customThickness.png new file mode 100644 index 0000000..ff4c4ef Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value0_joinJoined_roundCorners_customThickness.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value0_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithLines.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value0_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithLines.png new file mode 100644 index 0000000..f18560a Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value0_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithLines.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value0_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithLinesWithoutLabels.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value0_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithLinesWithoutLabels.png new file mode 100644 index 0000000..8ca542d Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value0_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithLinesWithoutLabels.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinJoined_roundCorners_customThickness.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinJoined_roundCorners_customThickness.png new file mode 100644 index 0000000..07303bc Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinJoined_roundCorners_customThickness.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithDotsWithoutLabels.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithDotsWithoutLabels.png new file mode 100644 index 0000000..46f1bf1 Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithDotsWithoutLabels.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_defaultLinearScaleWithDots.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_defaultLinearScaleWithDots.png new file mode 100644 index 0000000..3d05a8e Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_defaultLinearScaleWithDots.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinOverlapped_roundCorners_noMinMaxLabel_defaultMainLabel_indicatorLine_nonLinearProgression_gradient.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinOverlapped_roundCorners_noMinMaxLabel_defaultMainLabel_indicatorLine_nonLinearProgression_gradient.png new file mode 100644 index 0000000..c9c8544 Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinOverlapped_roundCorners_noMinMaxLabel_defaultMainLabel_indicatorLine_nonLinearProgression_gradient.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinOverlapped_roundCorners_noMinMaxLabel_defaultMainLabel_indicatorLine_nonLinearProgression_gradientWithStops.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinOverlapped_roundCorners_noMinMaxLabel_defaultMainLabel_indicatorLine_nonLinearProgression_gradientWithStops.png new file mode 100644 index 0000000..2fc9d91 Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value100_joinOverlapped_roundCorners_noMinMaxLabel_defaultMainLabel_indicatorLine_nonLinearProgression_gradientWithStops.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value50_joinJoined_roundCorners_customThickness.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value50_joinJoined_roundCorners_customThickness.png new file mode 100644 index 0000000..68a21c8 Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value50_joinJoined_roundCorners_customThickness.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value50_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value50_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel.png new file mode 100644 index 0000000..9556122 Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value50_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value50_joinWithDegreeGap5_squareCorners_customMinMaxLabel_defaultMainLabel_indicatorArrow.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value50_joinWithDegreeGap5_squareCorners_customMinMaxLabel_defaultMainLabel_indicatorArrow.png new file mode 100644 index 0000000..6768d3c Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_range0To100_value50_joinWithDegreeGap5_squareCorners_customMinMaxLabel_defaultMainLabel_indicatorArrow.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_rangeMinus20To80_value50_joinJoined_roundCorners_defaultUI.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_rangeMinus20To80_value50_joinJoined_roundCorners_defaultUI.png new file mode 100644 index 0000000..cc0ddd0 Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.dial.DialChartTest_rangeMinus20To80_value50_joinJoined_roundCorners_defaultUI.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_intermittentValues_Y_noRounding_noTitle_X_roundToDays_customMarkers_hideOverlapping_alignToEdges_C_customLegend_doNotInterpolateOverNullValues.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_intermittentValues_Y_noRounding_noTitle_X_roundToDays_customMarkers_hideOverlapping_alignToEdges_C_customLegend_doNotInterpolateOverNullValues.png new file mode 100644 index 0000000..4245017 Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_intermittentValues_Y_noRounding_noTitle_X_roundToDays_customMarkers_hideOverlapping_alignToEdges_C_customLegend_doNotInterpolateOverNullValues.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_intermittentValues_Y_noRounding_noTitle_max2Line_X_roundToDays_customMarkers_hideOverlapping_alignToEdges_max3LinesC_customLegend.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_intermittentValues_Y_noRounding_noTitle_max2Line_X_roundToDays_customMarkers_hideOverlapping_alignToEdges_max3LinesC_customLegend.png new file mode 100644 index 0000000..930ca43 Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_intermittentValues_Y_noRounding_noTitle_max2Line_X_roundToDays_customMarkers_hideOverlapping_alignToEdges_max3LinesC_customLegend.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAboveAndBelowZero_Y_roundedToPoint4_titleOnTheRight_X_noRounding_customMarkers_alignToEdges_C_noLegend_dotsOnValues.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAboveAndBelowZero_Y_roundedToPoint4_titleOnTheRight_X_noRounding_customMarkers_alignToEdges_C_noLegend_dotsOnValues.png new file mode 100644 index 0000000..d15bf39 Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAboveAndBelowZero_Y_roundedToPoint4_titleOnTheRight_X_noRounding_customMarkers_alignToEdges_C_noLegend_dotsOnValues.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAtLeast5ForceShowing0Line_Y_roundedTo4_titleOnTheLeft_X_noRounding_C_noLegend.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAtLeast5ForceShowing0Line_Y_roundedTo4_titleOnTheLeft_X_noRounding_C_noLegend.png new file mode 100644 index 0000000..82053de Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAtLeast5ForceShowing0Line_Y_roundedTo4_titleOnTheLeft_X_noRounding_C_noLegend.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAtLeast5_Y_justOneValue_noRounding_titleOnTheTop_X_noRounding_customMarkers_alignToEdges_C_defaultLegend.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAtLeast5_Y_justOneValue_noRounding_titleOnTheTop_X_noRounding_customMarkers_alignToEdges_C_defaultLegend.png new file mode 100644 index 0000000..9ab1b8f Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAtLeast5_Y_justOneValue_noRounding_titleOnTheTop_X_noRounding_customMarkers_alignToEdges_C_defaultLegend.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAtLeast5_Y_roundedTo4_noTitle_X_noRounding_C_noLegend.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAtLeast5_Y_roundedTo4_noTitle_X_noRounding_C_noLegend.png new file mode 100644 index 0000000..2428d5f Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesAtLeast5_Y_roundedTo4_noTitle_X_noRounding_C_noLegend.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesBelowMinus5ForceShowing0Line_Y_justOneValue_noRounding_titleOnTheTop_X_noRounding_customMarkers_alignToEdges_C_defaultLegend.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesBelowMinus5ForceShowing0Line_Y_justOneValue_noRounding_titleOnTheTop_X_noRounding_customMarkers_alignToEdges_C_defaultLegend.png new file mode 100644 index 0000000..78e2adf Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesBelowMinus5ForceShowing0Line_Y_justOneValue_noRounding_titleOnTheTop_X_noRounding_customMarkers_alignToEdges_C_defaultLegend.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesBelowMinus5_Y_justOneValue_noRounding_titleOnTheTop_X_noRounding_customMarkers_alignToEdges_C_defaultLegend.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesBelowMinus5_Y_justOneValue_noRounding_titleOnTheTop_X_noRounding_customMarkers_alignToEdges_C_defaultLegend.png new file mode 100644 index 0000000..78e2adf Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesBelowMinus5_Y_justOneValue_noRounding_titleOnTheTop_X_noRounding_customMarkers_alignToEdges_C_defaultLegend.png differ diff --git a/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesBelowMinus5_Y_roundedToPoint4_titleOnTheRight_X_noRounding_customMarkers_alignToEdges_C_noLegend_dotsOnValues.png b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesBelowMinus5_Y_roundedToPoint4_titleOnTheRight_X_noRounding_customMarkers_alignToEdges_C_noLegend_dotsOnValues.png new file mode 100644 index 0000000..40832b8 Binary files /dev/null and b/example-app/android/screenshots/debug/Pixel_4_API_32/com.netguru.multiplatform.charts.line.LineChartTest_valuesBelowMinus5_Y_roundedToPoint4_titleOnTheRight_X_noRounding_customMarkers_alignToEdges_C_noLegend_dotsOnValues.png differ diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/HelpersKtTest.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/HelpersKtTest.kt new file mode 100644 index 0000000..9f4f397 --- /dev/null +++ b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/HelpersKtTest.kt @@ -0,0 +1,71 @@ +package com.netguru.multiplatform.charts + +import org.junit.Test + +class HelpersKtTest { + + @Test + fun roundToMultiplicationOf_initialIsPositive_roundToCeiling() { + val initial = 20f + val target = 30f + val multiplication = 15f + + val result = initial.roundToMultiplicationOf(multiplication, true) + assert(result == target) { "result should be $target, but was $result" } + } + + @Test + fun roundToMultiplicationOf_initialIsPositive_roundToFloor() { + val initial = 20f + val target = 15f + val multiplication = 15f + + val result = initial.roundToMultiplicationOf(multiplication, false) + assert(result == target) { "result should be $target, but was $result" } + } + + @Test + fun roundToMultiplicationOf_initialIsNegative_roundToCeiling() { + val initial = -20f + val target = -15f + val multiplication = 15f + + val result = initial.roundToMultiplicationOf(multiplication, true) + assert(result == target) { "result should be $target, but was $result" } + } + + @Test + fun roundToMultiplicationOf_initialIsNegative_roundToFloor() { + val initial = -20f + val target = -30f + val multiplication = 15f + + val result = initial.roundToMultiplicationOf(multiplication, false) + assert(result == target) { "result should be $target, but was $result" } + } + + @Test + fun roundToMultiplicationOf_initialIsZero_roundToFloor_resultIsZero() { + val initial = 0f + val target = 0f + val multiplication = 15f + + val result = initial.roundToMultiplicationOf(multiplication, false) + assert(result == target) { "result should be $target, but was $result" } + } + + @Test + fun roundToMultiplicationOf_initialIsZero_roundToCeiling_resultIsZero() { + val initial = 0f + val target = 0f + val multiplication = 15f + + val result = initial.roundToMultiplicationOf(multiplication, true) + assert(result == target) { "result should be $target, but was $result" } + } + + @Test(expected = IllegalArgumentException::class) + fun roundToMultiplicationOf_multiplicandIsZero_exceptionIsThrown() { + 1f.roundToMultiplicationOf(0f, true) + } +} diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/BarTest.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/BarTest.kt index 1c04561..7715643 100644 --- a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/BarTest.kt +++ b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/BarTest.kt @@ -44,7 +44,7 @@ class BarTest : ScreenshotTest { maxHorizontalLinesCount = 2, ), colors = ChartDefaults.chartColors(grid = Color.Red).barChartColors, - xAxisLabel = { + xAxisMarkerLayout = { Column( horizontalAlignment = Alignment.CenterHorizontally, ) { @@ -52,7 +52,7 @@ class BarTest : ScreenshotTest { Text(text = "testing") } }, - yAxisLabel = { + yAxisMarkerLayout = { Column( horizontalAlignment = Alignment.CenterHorizontally, ) { diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/BarWithLegendTest.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/BarWithLegendTest.kt index cfda12d..365e687 100644 --- a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/BarWithLegendTest.kt +++ b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/BarWithLegendTest.kt @@ -64,8 +64,8 @@ class BarWithLegendTest : ScreenshotTest { Text(text = "units") } }, - legendItemLabel = { - Text(text = "Custom label for: $it") + legendItemLabel = { name, unit -> + Text(text = "Custom label for: $name") } ) } diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/Data.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/Data.kt index 778df82..ba528a2 100644 --- a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/Data.kt +++ b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/bar/Data.kt @@ -54,7 +54,8 @@ internal object Data { fun generateData(nOfCategories: Int, nOfEntries: Int, valueTypes: ValueTypes): BarChartData { return BarChartData( - categories = generateCategories(nOfCategories, nOfEntries, valueTypes) + categories = generateCategories(nOfCategories, nOfEntries, valueTypes), + unit = "unit", ) } } diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/Arrow.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/Arrow.kt new file mode 100644 index 0000000..4da2b32 --- /dev/null +++ b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/Arrow.kt @@ -0,0 +1,81 @@ +package com.netguru.multiplatform.charts.dial + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.EvenOdd +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +internal val Arrow: ImageVector + get() { + if (_arrow != null) { + return _arrow!! + } + val width = 30f + val height = 30f + val strokeLineWidth = 2f + val strokeHalf = strokeLineWidth / 2 + _arrow = Builder( + name = "Arrow", + defaultWidth = width.dp, + defaultHeight = height.dp, + viewportWidth = width, + viewportHeight = height + ).apply { + path( + fill = null, + stroke = SolidColor(Color.White), + strokeLineWidth = strokeLineWidth, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + pathFillType = EvenOdd, + ) { + moveTo(width / 2, strokeHalf) + lineTo(width - strokeHalf, height - strokeHalf) + } + + path( + fill = null, + stroke = SolidColor(Color.White), + strokeLineWidth = strokeLineWidth, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + pathFillType = EvenOdd, + ) { + moveTo(width - strokeHalf, height - strokeHalf) + horizontalLineTo(strokeHalf) + } + + path( + fill = null, + stroke = SolidColor(Color.White), + strokeLineWidth = strokeLineWidth, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + pathFillType = EvenOdd, + ) { + moveTo(strokeHalf, height - strokeHalf) + lineTo(width / 2, strokeHalf) + } + + path( + fill = SolidColor(Color.White), + stroke = null, + pathFillType = EvenOdd, + ) { + moveTo(width / 2, strokeHalf) + lineTo(width - strokeHalf, height - strokeHalf) + horizontalLineTo(strokeHalf) + lineTo(width / 2, strokeHalf) + close() + } + } + .build() + return _arrow!! + } + +private var _arrow: ImageVector? = null diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/DialChartTest.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/DialChartTest.kt new file mode 100644 index 0000000..e67de0e --- /dev/null +++ b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/DialChartTest.kt @@ -0,0 +1,341 @@ +package com.netguru.multiplatform.charts.dial + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import com.karumi.shot.ScreenshotTest +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.Util.checkComposable +import com.netguru.multiplatform.charts.dial.scale.MarkType +import com.netguru.multiplatform.charts.dial.scale.ScaleConfig +import com.netguru.multiplatform.charts.line.Progression +import org.junit.Rule +import org.junit.Test + +class DialChartTest : ScreenshotTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun range0To100_value50_joinJoined_roundCorners_customThickness() { + checkComposable(composeRule) { + Dial( + value = 50f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.Joined, + roundCorners = true, + thickness = 25.dp, + ), + minAndMaxValueLabel = null, + mainLabel = null, + scaleConfig = null, + ) + } + } + + @Test + fun rangeMinus20To80_value50_joinJoined_roundCorners_defaultUI() { + checkComposable(composeRule) { + Dial( + value = 50f, + minValue = -20f, + maxValue = 80f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.Joined, + roundCorners = true, + ), + ) + } + } + + @Test + fun range0To100_value0_joinJoined_roundCorners_customThickness() { + checkComposable(composeRule) { + Dial( + value = 0f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.Joined, + roundCorners = true, + thickness = 25.dp, + ), + minAndMaxValueLabel = null, + mainLabel = null, + scaleConfig = null, + ) + } + } + + @Test + fun range0To100_value100_joinJoined_roundCorners_customThickness() { + checkComposable(composeRule) { + Dial( + value = 100f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.Joined, + roundCorners = true, + thickness = 25.dp, + ), + minAndMaxValueLabel = null, + mainLabel = null, + scaleConfig = null, + ) + } + } + + @Test + fun range0To100_value50_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel() { + checkComposable(composeRule) { + Dial( + value = 50f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.Joined, + roundCorners = false, + ), + minAndMaxValueLabel = { + Text(text = it.toString()) + }, + scaleConfig = null, + ) + } + } + + @Test + fun range0To100_value0_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithLines() { + checkComposable(composeRule) { + Dial( + value = 0f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.Joined, + roundCorners = false, + ), + minAndMaxValueLabel = { + Text(text = it.toString()) + }, + scaleConfig = ScaleConfig.LinearProgressionConfig( + scalePadding = 10.dp, + scaleLineWidth = 4.dp, + scaleLineLength = 26.dp, + scaleLabelLayout = { + Text(text = it.toString()) + }, + markType = MarkType.Line, + smallMarkStep = 1f, + bigMarkStep = 5f, + ), + ) + } + } + + @Test + fun range0To100_value100_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_defaultLinearScaleWithDots() { + checkComposable(composeRule) { + Dial( + value = 100f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.Joined, + roundCorners = false, + ), + minAndMaxValueLabel = { + Text(text = it.toString()) + }, + scaleConfig = ScaleConfig.LinearProgressionConfig( + scaleLabelLayout = { + Text(text = it.toString()) + }, + markType = MarkType.Dot, + ), + ) + } + } + + @Test + fun range0To100_value0_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithLinesWithoutLabels() { + checkComposable(composeRule) { + Dial( + value = 0f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.Joined, + roundCorners = false, + ), + minAndMaxValueLabel = { + Text(text = it.toString()) + }, + scaleConfig = ScaleConfig.LinearProgressionConfig( + scalePadding = 10.dp, + scaleLineWidth = 4.dp, + scaleLineLength = 26.dp, + scaleLabelLayout = null, + markType = MarkType.Line, + smallMarkStep = 1f, + bigMarkStep = 5f, + ), + ) + } + } + + @Test + fun range0To100_value100_joinJoined_squareCorners_customMinMaxLabel_defaultMainLabel_customizedLinearScaleWithDotsWithoutLabels() { + checkComposable(composeRule) { + Dial( + value = 100f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.Joined, + roundCorners = false, + ), + minAndMaxValueLabel = { + Text(text = it.toString()) + }, + scaleConfig = ScaleConfig.LinearProgressionConfig( + scalePadding = 10.dp, + scaleLineWidth = 4.dp, + scaleLineLength = 26.dp, + scaleLabelLayout = null, + markType = MarkType.Dot, + smallMarkStep = 1f, + bigMarkStep = 5f, + ), + ) + } + } + + @Test + fun range0To100_value50_joinWithDegreeGap5_squareCorners_customMinMaxLabel_defaultMainLabel_indicatorArrow() { + checkComposable(composeRule) { + Dial( + value = 50f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.WithDegreeGap(5f), + roundCorners = false, + ), + minAndMaxValueLabel = { + Text(text = it.toString()) + }, + scaleConfig = null, + indicator = { + Image( + painter = rememberVectorPainter(image = Arrow), + contentDescription = null, + modifier = Modifier + .padding( + start = 20.dp, + ) + .rotate(-90f) + ) + }, + ) + } + } + + @Test + fun range0To100_value100_joinOverlapped_roundCorners_noMinMaxLabel_defaultMainLabel_indicatorLine_nonLinearProgression_gradientWithStops() { + checkComposable(composeRule) { + Dial( + value = 100f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.Overlapped, + roundCorners = true, + fullAngleInDegrees = 270f, + ), + minAndMaxValueLabel = null, + scaleConfig = null, + progression = Progression.NonLinear( + anchorPoints = listOf( + Progression.NonLinear.AnchorPoint(0f, 0f), + Progression.NonLinear.AnchorPoint(20f, 0.1f), + Progression.NonLinear.AnchorPoint(40f, 0.2f), + Progression.NonLinear.AnchorPoint(60f, 0.3f), + Progression.NonLinear.AnchorPoint(80f, 0.8f), + Progression.NonLinear.AnchorPoint(90f, 0.9f), + Progression.NonLinear.AnchorPoint(100f, 1f), + ) + ), + indicator = { + Box( + modifier = Modifier + .background(Color.Red) + .fillMaxWidth() + .height(1.dp) + ) + }, + colors = DialChartDefaults.dialChartColors( + progressBarColor = DialProgressColors.GradientWithStops( + listOf( + 0f to Color.Yellow, + 0.2f to Color.Blue, + 0.8f to Color.Red, + 1f to Color.Green, + ) + ) + ) + ) + } + } + + @Test + fun range0To100_value100_joinOverlapped_roundCorners_noMinMaxLabel_defaultMainLabel_indicatorLine_nonLinearProgression_gradient() { + checkComposable(composeRule) { + Dial( + value = 100f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + joinStyle = DialJoinStyle.Overlapped, + roundCorners = true, + fullAngleInDegrees = 270f, + ), + minAndMaxValueLabel = null, + scaleConfig = null, + progression = Progression.NonLinear( + anchorPoints = listOf( + Progression.NonLinear.AnchorPoint(0f, 0f), + Progression.NonLinear.AnchorPoint(20f, 0.1f), + Progression.NonLinear.AnchorPoint(40f, 0.2f), + Progression.NonLinear.AnchorPoint(60f, 0.3f), + Progression.NonLinear.AnchorPoint(80f, 0.8f), + Progression.NonLinear.AnchorPoint(90f, 0.9f), + Progression.NonLinear.AnchorPoint(100f, 1f), + ) + ), + indicator = { + Box( + modifier = Modifier + .background(Color.Red) + .fillMaxWidth() + .height(1.dp) + ) + }, + colors = DialChartDefaults.dialChartColors( + progressBarColor = DialProgressColors.Gradient( + listOf( + Color.Yellow, + Color.Blue, + Color.Red, + Color.Green, + ) + ) + ) + ) + } + } +} diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/DialTest.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/DialTest.kt deleted file mode 100644 index dff7216..0000000 --- a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/DialTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package com.netguru.multiplatform.charts.dial - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.karumi.shot.ScreenshotTest -import com.netguru.multiplatform.charts.theme.ChartDefaults -import com.netguru.multiplatform.charts.Util.checkComposable -import org.junit.Rule -import org.junit.Test - -class DialTest : ScreenshotTest { - - @get:Rule - val composeRule = createComposeRule() - - @Test - fun range_0_100_value_50_UI_default() { - checkComposable(composeRule) { Dial(value = 50, minValue = 0, maxValue = 100) } - } - - @Test - fun range_0_100_value_0_UI_default() { - checkComposable(composeRule) { Dial(value = 0, minValue = 0, maxValue = 100) } - } - - @Test - fun range_0_100_value_100_UI_default() { - checkComposable(composeRule) { Dial(value = 100, minValue = 0, maxValue = 100) } - } - - @Test - fun range_0_100_value_minus50_UI_default() { - checkComposable(composeRule) { Dial(value = -50, minValue = 0, maxValue = 100) } - } - - @Test - fun range_0_100_value_150_UI_default() { - checkComposable(composeRule) { Dial(value = 150, minValue = 0, maxValue = 100) } - } - - @Test - fun range_0_100_value_69_UI_custom_colors_and_labels() { - checkComposable(composeRule) { - Dial( - value = 69, - minValue = 0, - maxValue = 100, - colors = ChartDefaults.chartColors( - primary = Color.Blue, - grid = Color.Magenta, - ).dialColors, - minAndMaxValueLabel = { - Text( - text = it.toString(), - color = Color.Blue, - fontSize = 32.sp, - modifier = Modifier.padding(top = 15.dp) - ) - }, - mainLabel = { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(text = it.toString(), color = Color.Blue, fontSize = 40.sp) - Text(text = "tests", color = Color.Magenta, fontSize = 24.sp) - } - } - ) - } - } - - @Test - fun range_0_100_value_69_UI_no_labels() { - checkComposable(composeRule) { - Dial( - value = 69, - minValue = 0, - maxValue = 100, - minAndMaxValueLabel = { }, - mainLabel = { } - ) - } - } -} diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/PercentageDialTest.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/PercentageDialTest.kt deleted file mode 100644 index d343bf7..0000000 --- a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/dial/PercentageDialTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package com.netguru.multiplatform.charts.dial - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.karumi.shot.ScreenshotTest -import com.netguru.multiplatform.charts.theme.ChartDefaults -import com.netguru.multiplatform.charts.Util.checkComposable -import org.junit.Rule -import org.junit.Test - -class PercentageDialTest : ScreenshotTest { - - @get:Rule - val composeRule = createComposeRule() - - @Test - fun value_50_UI_default() { - checkComposable(composeRule) { PercentageDial(percentage = 50) } - } - - @Test - fun value_0_UI_default() { - checkComposable(composeRule) { PercentageDial(percentage = 0) } - } - - @Test - fun value_100_UI_default() { - checkComposable(composeRule) { PercentageDial(percentage = 100) } - } - - @Test - fun value_minus100_UI_default() { - checkComposable(composeRule) { PercentageDial(percentage = -100) } - } - - @Test - fun value_150_UI_default() { - checkComposable(composeRule) { PercentageDial(percentage = 150) } - } - - @Test - fun value_69_UI_custom_colors_and_labels() { - checkComposable(composeRule) { - PercentageDial( - percentage = 69, - colors = ChartDefaults.chartColors( - primary = Color.Blue, - grid = Color.Magenta, - ).dialColors, - minAndMaxValueLabel = { - Text( - text = it.toString(), - color = Color.Blue, - fontSize = 32.sp, - modifier = Modifier.padding(top = 15.dp) - ) - }, - mainLabel = { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text(text = it.toString(), color = Color.Blue, fontSize = 40.sp) - Text(text = "tests", color = Color.Magenta, fontSize = 24.sp) - } - } - ) - } - } - - @Test - fun value_69_UI_no_labels() { - checkComposable(composeRule) { - PercentageDial( - percentage = 69, - minAndMaxValueLabel = { }, - mainLabel = { } - ) - } - } -} diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScaleDynamicTest.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScaleDynamicTest.kt new file mode 100644 index 0000000..7f6793f --- /dev/null +++ b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/grid/axisscale/y/YAxisScaleDynamicTest.kt @@ -0,0 +1,490 @@ +package com.netguru.multiplatform.charts.grid.axisscale.y + +import org.junit.Test + + +class YAxisScaleDynamicTest { + +// /////////////////////////////////////////// +// // min value rounding, non-negative numbers +// /////////////////////////////////////////// +// @Test +// fun minValueOf10_roundedTo10_shouldBe10() { +// val scale = YAxisScaleDynamic( +// minDataValue = 10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 10f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == 10f) { "scale.min should be 10.0, was: ${scale.min}" } +// } +// +// @Test +// fun minValueOf10_roundedTo2_shouldBe10() { +// val scale = YAxisScaleDynamic( +// minDataValue = 10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 2f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == 10f) { "scale.min should be 10.0, was: ${scale.min}" } +// } +// +// @Test +// fun minValueOf10_roundedTo9_shouldBe9() { +// val scale = YAxisScaleDynamic( +// minDataValue = 10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 9f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == 9f) { "scale.min should be 9.0, was: ${scale.min}" } +// } +// +// @Test +// fun minValueOf10_roundedTo3_shouldBe9() { +// val scale = YAxisScaleDynamic( +// minDataValue = 10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 3f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == 9f) { "scale.min should be 9.0, was: ${scale.min}" } +// } +// +// @Test +// fun minValueOf10_roundedTo20_shouldBe0() { +// val scale = YAxisScaleDynamic( +// minDataValue = 10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 20f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == 0f) { "scale.min should be 0.0, was: ${scale.min}" } +// } +// +// @Test +// fun minValueOf1Point7_roundedTo0Point5_shouldBe1Point5() { +// val scale = YAxisScaleDynamic( +// minDataValue = 1.7f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 0.5f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == 1.5f) { "scale.min should be 1.5, was: ${scale.min}" } +// } +// +// @Test +// fun minValueOf0_roundedToAnythingPositive_shouldBe0() { +// (1..250).forEach { +// val roundTo = it / 2f +// val scale = YAxisScaleDynamic( +// minDataValue = 0f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = roundTo, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == 0f) { "scale.min rounded to $roundTo should be 0.0, was: ${scale.min}" } +// } +// } +// +// @Test +// fun minValueOfAnything_roundToNull_shouldNotBeRounded() { +// (-125..125).forEach { +// val minValue = it / 2f +// val scale = YAxisScaleDynamic( +// minDataValue = minValue, +// maxDataValue = 200f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = null, +// roundMarkersToMultiplicationOf = null, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == minValue) { "scale.min rounded to null should be $minValue (not rounded at all), was: ${scale.min}" } +// } +// } +// +// /////////////////////////////////////////// +// // min value rounding, negative numbers +// /////////////////////////////////////////// +// @Test +// fun minValueOfMinus10_roundedTo10_shouldBeMinus10() { +// val scale = YAxisScaleDynamic( +// minDataValue = -10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 10f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == -10f) { "scale.min should be -10.0, was: ${scale.min}" } +// } +// +// @Test +// fun minValueOfMinus10_roundedTo2_shouldBeMinus10() { +// val scale = YAxisScaleDynamic( +// minDataValue = -10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 2f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == -10f) { "scale.min should be -10.0, was: ${scale.min}" } +// } +// +// @Test +// fun minValueOfMinus10_roundedTo9_shouldBeMinus18() { +// val scale = YAxisScaleDynamic( +// minDataValue = -10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 9f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == -18f) { "scale.min should be -18.0, was: ${scale.min}" } +// } +// +// @Test +// fun minValueOfMinus10_roundedTo3_shouldBeMinus12() { +// val scale = YAxisScaleDynamic( +// minDataValue = -10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 3f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == -12f) { "scale.min should be -12.0, was: ${scale.min}" } +// } +// +// @Test +// fun minValueOfMinus10_roundedTo20_shouldBeMinus20() { +// val scale = YAxisScaleDynamic( +// minDataValue = -10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 20f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == -20f) { "scale.min should be -20.0, was: ${scale.min}" } +// } +// +// @Test +// fun minValueOfMinus1Point7_roundedTo0Point5_shouldBeMinus2() { +// val scale = YAxisScaleDynamic( +// minDataValue = -1.7f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 0.5f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.min == -2f) { "scale.min should be -2.0, was: ${scale.min}" } +// } +// +// /////////////////////////////////////////// +// // rounding to non-positive +// /////////////////////////////////////////// +// @Test(expected = IllegalArgumentException::class) +// fun valuesOfAnything_roundingMinToNegative_shouldThrowExceptionWhenCreatingTheInstance() { +// YAxisScaleDynamic( +// minDataValue = 10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = -3f, +// roundMarkersToMultiplicationOf = 1f, +// forceShowingValueZeroLine = false, +// ) +// } +// +// @Test(expected = IllegalArgumentException::class) +// fun valuesOfAnything_roundingMinToZero_shouldThrowExceptionWhenCreatingTheInstance() { +// YAxisScaleDynamic( +// minDataValue = 10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 0f, +// roundMarkersToMultiplicationOf = 1f, +// forceShowingValueZeroLine = false, +// ) +// } +// +// @Test(expected = IllegalArgumentException::class) +// fun valuesOfAnything_roundingMaxToNegative_shouldThrowExceptionWhenCreatingTheInstance() { +// YAxisScaleDynamic( +// minDataValue = 10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 1f, +// roundMarkersToMultiplicationOf = -3f, +// forceShowingValueZeroLine = false, +// ) +// } +// +// @Test(expected = IllegalArgumentException::class) +// fun valuesOfAnything_roundingMaxToZero_shouldThrowExceptionWhenCreatingTheInstance() { +// YAxisScaleDynamic( +// minDataValue = 10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 0f, +// forceShowingValueZeroLine = false, +// ) +// } +// +// +// /////////////////////////////////////////// +// // max value rounding, non-negative numbers +// /////////////////////////////////////////// +// @Test +// fun maxValueOf10_roundedTo10_shouldBe10() { +// val scale = YAxisScaleDynamic( +// minDataValue = 0f, +// maxDataValue = 10f, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = 10f, +// roundMarkersToMultiplicationOf = 10f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == 10f) { "scale.max should be 10.0, was: ${scale.max}" } +// } +// +// @Test +// fun maxValueOf10_roundedTo2_shouldBe10() { +// val scale = YAxisScaleDynamic( +// minDataValue = 0f, +// maxDataValue = 10f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 2f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == 10f) { "scale.max should be 10.0, was: ${scale.max}" } +// } +// +// @Test +// fun maxValueOf10_roundedTo9_shouldBe18() { +// val scale = YAxisScaleDynamic( +// minDataValue = 0f, +// maxDataValue = 10f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 9f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == 18f) { "scale.max should be 18.0, was: ${scale.max}" } +// } +// +// @Test +// fun maxValueOf10_roundedTo3_shouldBe12() { +// val scale = YAxisScaleDynamic( +// minDataValue = 0f, +// maxDataValue = 10f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 3f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == 12f) { "scale.max should be 12.0, was: ${scale.max}" } +// } +// +// @Test +// fun maxValueOf10_roundedTo20_shouldBe20() { +// val scale = YAxisScaleDynamic( +// minDataValue = 0f, +// maxDataValue = 10f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 20f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == 20f) { "scale.max should be 20.0, was: ${scale.max}" } +// } +// +// @Test +// fun maxValueOf1Point7_roundedTo0Point5_shouldBe2() { +// val scale = YAxisScaleDynamic( +// minDataValue = 0f, +// maxDataValue = 1.7f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 0.5f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == 2f) { "scale.max should be 2.0, was: ${scale.max}" } +// } +// +// @Test +// fun maxValueOf0_roundedToAnythingPositive_shouldBe0() { +// (1..250).forEach { +// val roundTo = it / 2f +// val scale = YAxisScaleDynamic( +// minDataValue = -10f, +// maxDataValue = 0f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = roundTo, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == 0f) { "scale.max rounded to $roundTo should be 0.0, was: ${scale.max}" } +// } +// } +// +// @Test +// fun maxValueOfAnything_roundToNull_shouldNotBeRounded() { +// (-125..125).forEach { +// val maxValue = it / 2f +// val scale = YAxisScaleDynamic( +// minDataValue = -200f, +// maxDataValue = maxValue, +// maxNumberOfHorizontalLines = 1, +// roundMinToMultiplicationOf = null, +// roundMarkersToMultiplicationOf = null, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == maxValue) { "scale.max rounded to null should be $maxValue (not rounded at all), was: ${scale.max}" } +// } +// } +// +// /////////////////////////////////////////// +// // max value rounding, negative numbers +// /////////////////////////////////////////// +// @Test +// fun maxValueOfMinus10_roundedTo10_shouldBeMinus10() { +// val scale = YAxisScaleDynamic( +// minDataValue = -100f, +// maxDataValue = -10f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 10f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == -10f) { "scale.max should be -10.0, was: ${scale.max}" } +// } +// +// @Test +// fun maxValueOfMinus10_roundedTo2_shouldBeMinus10() { +// val scale = YAxisScaleDynamic( +// minDataValue = -100f, +// maxDataValue = -10f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 2f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == -10f) { "scale.max should be -10.0, was: ${scale.max}" } +// } +// +// @Test +// fun maxValueOfMinus10_roundedTo9_shouldBeMinus9() { +// val scale = YAxisScaleDynamic( +// minDataValue = -100f, +// maxDataValue = -10f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 9f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == -9f) { "scale.max should be -9.0, was: ${scale.max}" } +// } +// +// @Test +// fun maxValueOfMinus10_roundedTo3_shouldBeMinus9() { +// val scale = YAxisScaleDynamic( +// minDataValue = -100f, +// maxDataValue = -10f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 3f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == -9f) { "scale.max should be -9.0, was: ${scale.max}" } +// } +// +// @Test +// fun maxValueOfMinus10_roundedTo20_shouldBe0() { +// val scale = YAxisScaleDynamic( +// minDataValue = -100f, +// maxDataValue = -10f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 20f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == 0f) { "scale.max should be 0.0, was: ${scale.max}" } +// } +// +// @Test +// fun maxValueOfMinus1Point7_roundedTo0Point5_shouldBeMinus1Point5() { +// val scale = YAxisScaleDynamic( +// minDataValue = -17f, +// maxDataValue = -1.7f, +// maxNumberOfHorizontalLines = 1, +// roundMaxToMultiplicationOf = 0.5f, +// forceShowingValueZeroLine = false, +// ) +// +// assert(scale.max == -1.5f) { "scale.max should be -1.5, was: ${scale.max}" } +// } +// +// +// /////////////////////////////////////////// +// // force showing value zero line +// /////////////////////////////////////////// +// @Test +// fun minValueGT0_notRoundedAndForceShowingValueZeroLine_minIsZero() { +// val scale = YAxisScaleDynamic( +// minDataValue = 10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// forceShowingValueZeroLine = true, +// ) +// +// assert(scale.min == 0f) { "scale.min should be forced to 0.0, was: ${scale.min}" } +// } +// +// @Test +// fun maxValueLT0_notRoundedAndForceShowingValueZeroLine_maxIsZero() { +// val scale = YAxisScaleDynamic( +// minDataValue = -100f, +// maxDataValue = -20f, +// maxNumberOfHorizontalLines = 1, +// forceShowingValueZeroLine = true, +// ) +// +// assert(scale.max == 0f) { "scale.max should be forced to 0.0, was: ${scale.max}" } +// } +// +// @Test +// fun minValueLT0AndMaxValueGT0_notRoundedAndForceShowingValueZeroLine_maxIsMaxMinIsMin() { +// val scale = YAxisScaleDynamic( +// minDataValue = -10f, +// maxDataValue = 20f, +// maxNumberOfHorizontalLines = 1, +// forceShowingValueZeroLine = true, +// ) +// +// assert(scale.min == -10f) { "scale.min should be -10.0, was: ${scale.min}" } +// assert(scale.max == 20f) { "scale.max should be 20.0, was: ${scale.max}" } +// } +} diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/Data.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/Data.kt index 9422f00..c64d728 100644 --- a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/Data.kt +++ b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/Data.kt @@ -1,8 +1,11 @@ package com.netguru.multiplatform.charts.line import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect import com.soywiz.klock.DateTime import com.soywiz.klock.TimeSpan +import kotlin.math.abs +import kotlin.math.sign internal object Data { @@ -18,25 +21,77 @@ internal object Data { Color.Red, ) - fun generateLineData(nOfLines: Int) : LineChartData { + /** + * @param numberOfLines Number of lines to draw in the chart + * @param numberOfPoints Number of points for each line + * @param distanceToZero The actual distance might be a bit off due to sign of the largest number. Also, to have + * the lines move down, you need to set this to a negative value. + * @param factor The factor to use to multiply the values with. [distanceToZero] is not affected by it. + */ + fun generateLineData( + numberOfLines: Int = 4, + numberOfPoints: Int = 5, + distanceToZero: Float = 0f, + factor: Float = 1f, + ): LineChartData { + + val offset = ((numberOfLines + numberOfPoints) * factor + abs(distanceToZero)) * sign(distanceToZero) + return LineChartData( - series = (1..nOfLines).map { + series = (1..numberOfLines).map { line -> LineChartSeries( - dataName = "data $it", - lineColor = colors[it % colors.size], - listOfPoints = (1..5).map { point -> - val sign = if((point+it) % 2 == 0) -1 else 1 - val value = (it + point) * sign + dataName = "data $line", + lineColor = colors[line % colors.size], + listOfPoints = (1..numberOfPoints).map { point -> + val sign = if ((point + line) % 2 == 0) -1 else 1 + val value = ((line + point) * sign * factor) + offset LineChartPoint( x = DateTime.fromString("2021-12-31") - .minus(TimeSpan(point * 24 * 60 * 60 * 1000.0)) + .plus(getTimesSpanForPoint(point)) .utc .unixMillisLong, - y = value.toFloat(), + y = value, ) } ) }, + dataUnit = "unit", ) } + + fun generateIntermittentLineData(): LineChartData { + return LineChartData( + series = listOf( + LineChartSeries( + dataName = "data left", + lineColor = colors[0], + listOfPoints = (1..7).map { point -> + LineChartPoint( + x = DateTime.fromString("2021-12-31") + .plus(getTimesSpanForPoint(point)) + .utc + .unixMillisLong, + y = if (point == 3 || point == 5) null else point.toFloat()-3.6f + ) + }, + ), + LineChartSeries( + dataName = "lots of data 2", + lineColor = colors[1], + listOfPoints = (1..7).map { point -> + LineChartPoint( + x = DateTime.fromString("2021-12-31") + .plus(getTimesSpanForPoint(point)) + .utc + .unixMillisLong, + y = if (point == 1 || point > 5) null else (6 - point).toFloat()-3.6f + ) + }, + ), + ), + dataUnit = "leftUnit", + ) + } + + private fun getTimesSpanForPoint(point: Int): TimeSpan = TimeSpan(point * 24 * 60 * 60 * 1000.0) } diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/LineChartTest.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/LineChartTest.kt new file mode 100644 index 0000000..ec296d3 --- /dev/null +++ b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/LineChartTest.kt @@ -0,0 +1,346 @@ +package com.netguru.multiplatform.charts.line + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.karumi.shot.ScreenshotTest +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.Util.checkComposable +import com.netguru.multiplatform.charts.grid.YAxisTitleData +import com.netguru.multiplatform.charts.grid.axisscale.y.YAxisScaleDynamic +import com.netguru.multiplatform.charts.vertical +import org.junit.Rule +import org.junit.Test +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +class LineChartTest : ScreenshotTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun valuesAtLeast5_Y_roundedTo4_noTitle_X_noRounding_C_noLegend() { + checkComposable(composeRule) { + val data = Data.generateLineData(distanceToZero = 5f) + LineChart( + data = data, + yAxisConfig = YAxisConfig( + markerLayout = { value -> + Text(text = value.toString()) + }, + yAxisTitleData = null, + scale = YAxisScaleDynamic( + chartData = data, + maxNumberOfHorizontalLines = Int.MAX_VALUE, + roundMarkersToMultiplicationOf = 4f, + forceShowingValueZeroLine = false, + ), + ), + xAxisConfig = XAxisConfig( + roundMarkersToMultiplicationOf = 1, // no rounding + ), + legendConfig = null, + displayAnimation = ChartDisplayAnimation.Disabled, + ) + } + } + + @Test + fun valuesAtLeast5ForceShowing0Line_Y_roundedTo4_titleOnTheLeft_X_noRounding_C_noLegend() { + checkComposable(composeRule) { + val data = Data.generateLineData(distanceToZero = 5f) + LineChart( + data = data, + yAxisConfig = YAxisConfig( + markerLayout = { value -> + Text(text = value.toString()) + }, + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = "data title", + modifier = Modifier + .vertical() + ) + }, + ), + scale = YAxisScaleDynamic( + chartData = data, + maxNumberOfHorizontalLines = Int.MAX_VALUE, + roundMarkersToMultiplicationOf = 4f, + forceShowingValueZeroLine = true, + ), + ), + xAxisConfig = XAxisConfig( + roundMarkersToMultiplicationOf = 1, // no rounding + ), + legendConfig = null, + displayAnimation = ChartDisplayAnimation.Disabled, + ) + } + } + + @Test + fun valuesAboveAndBelowZero_Y_roundedToPoint4_titleOnTheRight_X_noRounding_customMarkers_alignToEdges_C_noLegend_dotsOnValues() { + checkComposable(composeRule) { + val data = Data.generateLineData(factor = 0.1f) + LineChart( + data = data, + yAxisConfig = YAxisConfig( + markerLayout = { value -> + Text(text = value.toString()) + }, + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = "data title", + modifier = Modifier + .vertical() + ) + }, + labelPosition = YAxisTitleData.LabelPosition.Right, + ), + scale = YAxisScaleDynamic( + chartData = data, + maxNumberOfHorizontalLines = Int.MAX_VALUE, + roundMarkersToMultiplicationOf = 0.4f, + ), + ), + xAxisConfig = XAxisConfig( + roundMarkersToMultiplicationOf = 1, // no rounding + markerLayout = { + val zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(it as Long), ZoneId.of("UTC")) + val str = zonedDateTime.format(DateTimeFormatter.BASIC_ISO_DATE) + Text(text = str) + }, + alignFirstAndLastToChartEdges = true, + ), + legendConfig = null, + displayAnimation = ChartDisplayAnimation.Disabled, + shouldDrawValueDots = true, + ) + } + } + + @Test + fun valuesBelowMinus5_Y_roundedToPoint4_titleOnTheRight_X_noRounding_customMarkers_alignToEdges_C_noLegend_dotsOnValues() { + checkComposable(composeRule) { + val data = Data.generateLineData(distanceToZero = -5f) + LineChart( + data = data, + yAxisConfig = YAxisConfig( + markerLayout = { value -> + Text(text = value.toString()) + }, + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = "data title", + modifier = Modifier + .vertical() + ) + }, + labelPosition = YAxisTitleData.LabelPosition.Right, + ), + scale = YAxisScaleDynamic( + chartData = data, + maxNumberOfHorizontalLines = Int.MAX_VALUE, + roundMarkersToMultiplicationOf = 0.4f, + forceShowingValueZeroLine = false, + ), + ), + xAxisConfig = XAxisConfig( + roundMarkersToMultiplicationOf = 1, // no rounding + markerLayout = { + val zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(it as Long), ZoneId.of("UTC")) + val str = zonedDateTime.format(DateTimeFormatter.BASIC_ISO_DATE) + Text(text = str) + }, + alignFirstAndLastToChartEdges = true, + ), + legendConfig = null, + displayAnimation = ChartDisplayAnimation.Disabled, + shouldDrawValueDots = true, + ) + } + } + + @Test + fun valuesAtLeast5_Y_justOneValue_noRounding_titleOnTheTop_X_noRounding_customMarkers_alignToEdges_C_defaultLegend() { + checkComposable(composeRule) { + val data = Data.generateLineData(numberOfLines = 1, numberOfPoints = 1, distanceToZero = 5f) + LineChart( + data = data, + yAxisConfig = YAxisConfig( + markerLayout = { value -> + Text(text = value.toString()) + }, + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = "data title", + ) + }, + labelPosition = YAxisTitleData.LabelPosition.Top, + ), + scale = YAxisScaleDynamic( + chartData = data, + maxNumberOfHorizontalLines = Int.MAX_VALUE, + roundMarkersToMultiplicationOf = null, + forceShowingValueZeroLine = false, + ), + ), + xAxisConfig = XAxisConfig( + roundMarkersToMultiplicationOf = 1, // no rounding + markerLayout = { + val zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(it as Long), ZoneId.of("UTC")) + val str = zonedDateTime.format(DateTimeFormatter.BASIC_ISO_DATE) + Text(text = str) + }, + alignFirstAndLastToChartEdges = true, + ), + legendConfig = LegendConfig(), + displayAnimation = ChartDisplayAnimation.Disabled, + shouldDrawValueDots = false, + ) + } + } + + @Test + fun valuesBelowMinus5ForceShowing0Line_Y_justOneValue_noRounding_titleOnTheTop_X_noRounding_customMarkers_alignToEdges_C_defaultLegend() { + checkComposable(composeRule) { + val data = Data.generateLineData(numberOfLines = 1, numberOfPoints = 1, distanceToZero = -5f) + LineChart( + data = data, + yAxisConfig = YAxisConfig( + markerLayout = { value -> + Text(text = value.toString()) + }, + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = "data title", + ) + }, + labelPosition = YAxisTitleData.LabelPosition.Top, + ), + scale = YAxisScaleDynamic( + chartData = data, + maxNumberOfHorizontalLines = Int.MAX_VALUE, + roundMarkersToMultiplicationOf = null, + forceShowingValueZeroLine = true, + ), + ), + xAxisConfig = XAxisConfig( + roundMarkersToMultiplicationOf = 1, // no rounding + markerLayout = { + val zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(it as Long), ZoneId.of("UTC")) + val str = zonedDateTime.format(DateTimeFormatter.BASIC_ISO_DATE) + Text(text = str) + }, + alignFirstAndLastToChartEdges = true, + ), + legendConfig = LegendConfig(), + displayAnimation = ChartDisplayAnimation.Disabled, + shouldDrawValueDots = false, + ) + } + } + + @Test + fun intermittentValues_Y_noRounding_noTitle_X_roundToDays_customMarkers_hideOverlapping_alignToEdges_C_customLegend_doNotInterpolateOverNullValues() { + checkComposable(composeRule) { + val data = Data.generateIntermittentLineData() + LineChart( + data = data, + yAxisConfig = YAxisConfig( + markerLayout = { value -> + Text(text = value.toString()) + }, + yAxisTitleData = null, + scale = YAxisScaleDynamic( + chartData = data, + roundMarkersToMultiplicationOf = null, + forceShowingValueZeroLine = false, + ), + ), + xAxisConfig = XAxisConfig( + roundMarkersToMultiplicationOf = 24 * 60 * 60 * 1000, // 1 day + markerLayout = { + val zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(it as Long), ZoneId.of("UTC")) + val str = zonedDateTime.format(DateTimeFormatter.BASIC_ISO_DATE) + Text(text = str) + }, + alignFirstAndLastToChartEdges = true, + hideMarkersWhenOverlapping = true, + ), + legendConfig = LegendConfig( + columnMinWidth = 50.dp, + legendItemLabel = { name, unit -> + Text( + text = "${name}_$unit", + fontSize = 10.sp, + ) + } + ), + displayAnimation = ChartDisplayAnimation.Disabled, + shouldDrawValueDots = true, + shouldInterpolateOverNullValues = false, + ) + } + } + + @Test + fun intermittentValues_Y_noRounding_noTitle_max2Line_X_roundToDays_customMarkers_hideOverlapping_alignToEdges_max3LinesC_customLegend() { + checkComposable(composeRule) { + val data = Data.generateIntermittentLineData() + LineChart( + data = data, + yAxisConfig = YAxisConfig( + markerLayout = { value -> + Text(text = value.toString()) + }, + yAxisTitleData = null, + scale = YAxisScaleDynamic( + chartData = data, + roundMarkersToMultiplicationOf = null, + forceShowingValueZeroLine = false, + maxNumberOfHorizontalLines = 2, + ), + ), + xAxisConfig = XAxisConfig( + roundMarkersToMultiplicationOf = 24 * 60 * 60 * 1000, // 1 day + markerLayout = { + val zonedDateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(it as Long), ZoneId.of("UTC")) + val day = zonedDateTime.format(DateTimeFormatter.BASIC_ISO_DATE) + val hour = zonedDateTime.format(DateTimeFormatter.ISO_LOCAL_TIME) + Column { + Text(text = day) + Text(text = hour) + } + }, + alignFirstAndLastToChartEdges = true, + hideMarkersWhenOverlapping = true, + maxVerticalLines = 3, + ), + legendConfig = LegendConfig( + columnMinWidth = 50.dp, + legendItemLabel = { name, unit -> + Text( + text = "${name}_$unit", + fontSize = 10.sp, + ) + } + ), + displayAnimation = ChartDisplayAnimation.Disabled, + shouldDrawValueDots = true, + ) + } + } +} diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/LineTest.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/LineTest.kt deleted file mode 100644 index 84c97ac..0000000 --- a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/LineTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.netguru.multiplatform.charts.line - -import androidx.compose.foundation.layout.Column -import androidx.compose.material.Text -import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.karumi.shot.ScreenshotTest -import com.netguru.multiplatform.charts.Util.checkComposable -import com.netguru.multiplatform.charts.theme.ChartDefaults -import com.soywiz.klock.DateTime -import org.junit.Rule -import org.junit.Test - -class LineTest : ScreenshotTest { - - @get:Rule - val composeRule = createComposeRule() - -// @Test // removed due to: https://github.com/pedrovgs/Shot/issues/265 -// fun mixedValues_defaultUI() { -// checkComposable(composeRule) { -// LineChart( -// lineChartData = Data.generateLineData(3) -// ) -// } -// } -// -// @Test // removed due to: https://github.com/pedrovgs/Shot/issues/265 -// fun mixedValues_customUI() { -// val data = Data.generateLineData(3) -// checkComposable(composeRule) { -// LineChart( -// lineChartData = data -// .copy( -// series = data.series.map { -// it.copy( -// lineWidth = 12.dp, -// fillColor = Color.Yellow, -// lineColor = Color.Blue, -// dashedLine = true, -// ) -// } -// ), -// chartColors = ChartDefaults.chartColors( -// grid = Color.Magenta, -// surface = Color.Cyan, -// overlayLine = Color.Gray, -// ), -// xAxisLabel = { -// Column( -// horizontalAlignment = Alignment.CenterHorizontally, -// ) { -// Text( -// fontSize = 12.sp, -// text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), -// textAlign = TextAlign.Center -// ) -// Text( -// text = "midday" -// ) -// } -// }, -// yAxisLabel = { -// Column( -// horizontalAlignment = Alignment.End -// ) { -// Text(text = it.toString()) -// Text(text = "units") -// } -// }, -// maxVerticalLines = 2, -// maxHorizontalLines = 2, -// ) -// } -// } -} diff --git a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/LineWithLegendTest.kt b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/LineWithLegendTest.kt index f22fed0..6276de9 100644 --- a/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/LineWithLegendTest.kt +++ b/example-app/android/src/androidTest/kotlin/com/netguru/multiplatform/charts/line/LineWithLegendTest.kt @@ -14,29 +14,33 @@ class LineWithLegendTest : ScreenshotTest { @get:Rule val composeRule = createComposeRule() -// @Test // removed due to: https://github.com/pedrovgs/Shot/issues/265 -// fun mixedValues_defaultUI() { -// checkComposable(composeRule) { -// LineChartWithLegend( -// lineChartData = Data.generateLineData(3), -// ) -// } -// } -// -// @Test // removed due to: https://github.com/pedrovgs/Shot/issues/265 -// fun mixedValues_customUI() { -// checkComposable(composeRule) { -// LineChartWithLegend( -// lineChartData = Data.generateLineData(3), -// legendItemLabel = { -// Column( -// horizontalAlignment = Alignment.CenterHorizontally -// ) { -// Text(text = it) -// Text(text = "line") -// } -// } -// ) -// } -// } + @Test // removed due to: https://github.com/pedrovgs/Shot/issues/265 + fun mixedValues_defaultUI() { + checkComposable(composeRule) { + LineChart( + data = Data.generateLineData(3), + ) + } + } + + @Test // removed due to: https://github.com/pedrovgs/Shot/issues/265 + fun mixedValues_customUI() { + checkComposable(composeRule) { + LineChart( + data = Data.generateLineData(3), + legendConfig = LegendConfig( + legendItemLabel = { name, unit -> + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text(text = name) + if (unit != null) { + Text(text = unit) + } + } + } + ), + ) + } + } } diff --git a/example-app/android/src/main/java/com/netguru/multiplatform/charts/android/previews/BarChartPreview.kt b/example-app/android/src/main/java/com/netguru/multiplatform/charts/android/previews/BarChartPreview.kt index f6cf58b..865e9e3 100644 --- a/example-app/android/src/main/java/com/netguru/multiplatform/charts/android/previews/BarChartPreview.kt +++ b/example-app/android/src/main/java/com/netguru/multiplatform/charts/android/previews/BarChartPreview.kt @@ -82,5 +82,6 @@ fun barChartSampleData(): BarChartData { ) ), ), + unit = "unit", ) } diff --git a/example-app/android/src/main/java/com/netguru/multiplatform/charts/android/previews/LineChartPreview.kt b/example-app/android/src/main/java/com/netguru/multiplatform/charts/android/previews/LineChartPreview.kt index 31e1b2f..2b8208a 100644 --- a/example-app/android/src/main/java/com/netguru/multiplatform/charts/android/previews/LineChartPreview.kt +++ b/example-app/android/src/main/java/com/netguru/multiplatform/charts/android/previews/LineChartPreview.kt @@ -1,32 +1,44 @@ package com.netguru.multiplatform.charts.android.previews +import android.graphics.PathEffect import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import com.netguru.multiplatform.charts.ChartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation import com.netguru.multiplatform.charts.common.AppTheme import com.netguru.multiplatform.charts.common.HOUR_IN_MS import com.netguru.multiplatform.charts.common.WindowSize +import com.netguru.multiplatform.charts.grid.axisscale.y.YAxisScaleDynamic +import com.netguru.multiplatform.charts.line.LineChart import com.netguru.multiplatform.charts.line.LineChartData import com.netguru.multiplatform.charts.line.LineChartPoint import com.netguru.multiplatform.charts.line.LineChartSeries -import com.netguru.multiplatform.charts.line.LineChartWithLegend +import com.netguru.multiplatform.charts.line.XAxisConfig +import com.netguru.multiplatform.charts.line.YAxisConfig import com.soywiz.klock.DateTime @Preview(showBackground = true, widthDp = 600) @Composable fun LineChartPreview() { AppTheme(windowSize = WindowSize.EXPANDED) { - LineChartWithLegend( + val data = getLineChartSampleData() + LineChart( + data = data, + yAxisConfig = YAxisConfig( + scale = YAxisScaleDynamic( + chartData = data, + ) + ), + xAxisConfig = XAxisConfig( + maxVerticalLines = 10, + ), modifier = Modifier .height(300.dp) .fillMaxWidth(), - lineChartData = getLineChartSampleData(), - maxVerticalLines = 10, - animation = ChartAnimation.Disabled, + displayAnimation = ChartDisplayAnimation.Disabled, ) } } @@ -40,7 +52,6 @@ private fun getLineChartSampleData(): LineChartData { LineChartSeries( "Solar", lineColor = AppTheme.colors.yellow, - dashedLine = false, listOfPoints = listOf( LineChartPoint(0L * HOUR_IN_MS + startTime, 0f), LineChartPoint(1L * HOUR_IN_MS + startTime, 1f), @@ -55,7 +66,6 @@ private fun getLineChartSampleData(): LineChartData { LineChartSeries( "Grid", lineColor = AppTheme.colors.green, - dashedLine = false, listOfPoints = listOf( LineChartPoint(0L * HOUR_IN_MS + startTime, 3f), LineChartPoint(1L * HOUR_IN_MS + startTime, 2f), @@ -69,7 +79,6 @@ private fun getLineChartSampleData(): LineChartData { LineChartSeries( "Fossil", lineColor = AppTheme.colors.blue, - dashedLine = false, listOfPoints = listOf( LineChartPoint(0L * HOUR_IN_MS + startTime, 1f), LineChartPoint(1L * HOUR_IN_MS + startTime, 3f), @@ -82,5 +91,6 @@ private fun getLineChartSampleData(): LineChartData { return LineChartData( series = list, + dataUnit = null, ) } diff --git a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/UiWrappers.kt b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/UiWrappers.kt index 74388b3..108b339 100644 --- a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/UiWrappers.kt +++ b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/UiWrappers.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp import com.netguru.multiplatform.charts.common.AppTheme @@ -24,6 +26,7 @@ fun TitleText( color = AppTheme.colors.primaryText, fontSize = 40.sp, fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center, modifier = modifier, ) } @@ -38,6 +41,19 @@ fun SpacedColumn( ) } +@Composable +fun PresentedItem( + text: String, + content: @Composable () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + TitleText(text) + content() + } +} + @Composable fun ScrollableScreen( content: @Composable ColumnScope.() -> Unit, diff --git a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/home/HomeMapper.kt b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/home/HomeMapper.kt index ba66848..f15bd8a 100644 --- a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/home/HomeMapper.kt +++ b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/home/HomeMapper.kt @@ -10,5 +10,6 @@ fun NavigationState.Tab.toLabel(): String = when (this) { NavigationState.Tab.DIAL -> "Dial" NavigationState.Tab.GAS_BOTTLE -> "Gas bottle" NavigationState.Tab.LINE -> "Line" + NavigationState.Tab.LINE_WITH_TWO_Y_AXIS -> "Line with two Y axis" NavigationState.Tab.PIE -> "Pie" } diff --git a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/home/HomeScreen.kt b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/home/HomeScreen.kt index 545eb9c..66da252 100644 --- a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/home/HomeScreen.kt +++ b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/home/HomeScreen.kt @@ -57,6 +57,7 @@ import com.netguru.multiplatform.charts.application.screen.BubbleChartScreen import com.netguru.multiplatform.charts.application.screen.DialChartScreen import com.netguru.multiplatform.charts.application.screen.GasBottleChartScreen import com.netguru.multiplatform.charts.application.screen.LineChartScreen +import com.netguru.multiplatform.charts.application.screen.LineChartWithTwoYAxisScreen import com.netguru.multiplatform.charts.application.screen.PieChartScreen import com.netguru.multiplatform.charts.common.AppTheme import com.netguru.multiplatform.charts.common.AppTheme.drawables @@ -127,6 +128,7 @@ private fun HomeContent(state: NavigationState, dispatch: (AppAction) -> Unit) { NavigationState.Tab.DIAL -> DialChartScreen() NavigationState.Tab.GAS_BOTTLE -> GasBottleChartScreen() NavigationState.Tab.LINE -> LineChartScreen() + NavigationState.Tab.LINE_WITH_TWO_Y_AXIS -> LineChartWithTwoYAxisScreen() NavigationState.Tab.PIE -> PieChartScreen() } } @@ -242,6 +244,7 @@ private fun TopHomeDrawer(state: NavigationState, dispatch: (AppAction) -> Unit) NavigationState.Tab.DIAL, NavigationState.Tab.GAS_BOTTLE, NavigationState.Tab.LINE, + NavigationState.Tab.LINE_WITH_TWO_Y_AXIS, NavigationState.Tab.PIE, ).forEach { tab -> HomeDrawerButton( diff --git a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/navigation/NavigationState.kt b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/navigation/NavigationState.kt index 882a163..a46cbff 100644 --- a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/navigation/NavigationState.kt +++ b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/navigation/NavigationState.kt @@ -10,6 +10,7 @@ data class NavigationState( DIAL, GAS_BOTTLE, LINE, + LINE_WITH_TWO_Y_AXIS, PIE, } } diff --git a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/Arrow.kt b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/Arrow.kt new file mode 100644 index 0000000..ee7dd87 --- /dev/null +++ b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/Arrow.kt @@ -0,0 +1,81 @@ +package com.netguru.multiplatform.charts.application.screen + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.EvenOdd +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +internal val Arrow: ImageVector + get() { + if (_arrow != null) { + return _arrow!! + } + val width = 30f + val height = 30f + val strokeLineWidth = 2f + val strokeHalf = strokeLineWidth / 2 + _arrow = Builder( + name = "Arrow", + defaultWidth = width.dp, + defaultHeight = height.dp, + viewportWidth = width, + viewportHeight = height + ).apply { + path( + fill = null, + stroke = SolidColor(Color.White), + strokeLineWidth = strokeLineWidth, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + pathFillType = EvenOdd, + ) { + moveTo(width / 2, strokeHalf) + lineTo(width - strokeHalf, height - strokeHalf) + } + + path( + fill = null, + stroke = SolidColor(Color.White), + strokeLineWidth = strokeLineWidth, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + pathFillType = EvenOdd, + ) { + moveTo(width - strokeHalf, height - strokeHalf) + horizontalLineTo(strokeHalf) + } + + path( + fill = null, + stroke = SolidColor(Color.White), + strokeLineWidth = strokeLineWidth, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + pathFillType = EvenOdd, + ) { + moveTo(strokeHalf, height - strokeHalf) + lineTo(width / 2, strokeHalf) + } + + path( + fill = SolidColor(Color.White), + stroke = null, + pathFillType = EvenOdd, + ) { + moveTo(width / 2, strokeHalf) + lineTo(width - strokeHalf, height - strokeHalf) + horizontalLineTo(strokeHalf) + lineTo(width / 2, strokeHalf) + close() + } + } + .build() + return _arrow!! + } + +private var _arrow: ImageVector? = null diff --git a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/BarChartScreen.kt b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/BarChartScreen.kt index ba75a90..5bb3910 100644 --- a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/BarChartScreen.kt +++ b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/BarChartScreen.kt @@ -8,7 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import com.netguru.multiplatform.charts.ChartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation import com.netguru.multiplatform.charts.application.ScrollableScreen import com.netguru.multiplatform.charts.application.SpacedColumn import com.netguru.multiplatform.charts.application.TitleText @@ -87,6 +87,7 @@ fun BarChartScreen() { ) ), ), + unit = "unit", ) ScrollableScreen { @@ -95,7 +96,7 @@ fun BarChartScreen() { BarChart( data = data, modifier = Modifier.height(500.dp), - yAxisLabel = { + yAxisMarkerLayout = { Text( text = it.toString(), color = AppTheme.colors.secondaryText, @@ -103,14 +104,14 @@ fun BarChartScreen() { modifier = Modifier.fillMaxWidth() ) }, - xAxisLabel = { + xAxisMarkerLayout = { Text( text = it.toString(), modifier = Modifier.padding(top = AppTheme.dimens.grid_2_5), color = AppTheme.colors.secondaryText ) }, - animation = ChartAnimation.Sequenced() + animation = ChartDisplayAnimation.Sequenced() ) HorizontalDivider() @@ -129,10 +130,10 @@ fun BarChartScreen() { color = AppTheme.colors.secondaryText ) }, - animation = ChartAnimation.Sequenced(), - legendItemLabel = { + animation = ChartDisplayAnimation.Sequenced(), + legendItemLabel = { name, unit -> Text( - text = it, + text = name + unit?.let { "\n($it)" }.orEmpty(), modifier = Modifier.padding(vertical = 10.dp) ) } diff --git a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/BubbleChartScreen.kt b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/BubbleChartScreen.kt index 02e3038..325cbe0 100644 --- a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/BubbleChartScreen.kt +++ b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/BubbleChartScreen.kt @@ -8,7 +8,7 @@ import androidx.compose.material.icons.filled.House import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.netguru.multiplatform.charts.ChartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation import com.netguru.multiplatform.charts.application.ScrollableScreen import com.netguru.multiplatform.charts.application.SpacedColumn import com.netguru.multiplatform.charts.application.TitleText @@ -46,7 +46,7 @@ fun BubbleChartScreen() { bubbles = bubbles, modifier = Modifier .size(300.dp), - animation = ChartAnimation.Sequenced(), + animation = ChartDisplayAnimation.Sequenced(), ) } } diff --git a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/DialChartScreen.kt b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/DialChartScreen.kt index aa30376..46af13d 100644 --- a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/DialChartScreen.kt +++ b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/DialChartScreen.kt @@ -2,90 +2,402 @@ package com.netguru.multiplatform.charts.application.screen import androidx.compose.animation.core.Spring import androidx.compose.animation.core.spring +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.MaterialTheme +import androidx.compose.material.Slider import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.unit.dp -import com.netguru.multiplatform.charts.ChartAnimation -import com.netguru.multiplatform.charts.application.ScrollableScreen -import com.netguru.multiplatform.charts.application.SpacedColumn -import com.netguru.multiplatform.charts.application.TitleText +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.application.PresentedItem import com.netguru.multiplatform.charts.common.AppTheme -import com.netguru.multiplatform.charts.common.HorizontalDivider import com.netguru.multiplatform.charts.dial.Dial +import com.netguru.multiplatform.charts.dial.DialChartColors import com.netguru.multiplatform.charts.dial.DialConfig -import com.netguru.multiplatform.charts.dial.PercentageDial +import com.netguru.multiplatform.charts.dial.DialJoinStyle +import com.netguru.multiplatform.charts.dial.DialProgressColors +import com.netguru.multiplatform.charts.dial.scale.MarkType +import com.netguru.multiplatform.charts.dial.scale.ScaleConfig +import com.netguru.multiplatform.charts.line.Progression +import com.netguru.multiplatform.charts.line.Progression.NonLinear.AnchorPoint +import kotlin.math.roundToInt @Composable fun DialChartScreen() { - ScrollableScreen { - SpacedColumn { - TitleText(text = "Percentage dial") + Column { + var sliderValue by remember { + mutableStateOf(-50f) + } + val commonModifier = Modifier + .padding(20.dp) + LazyVerticalGrid( + columns = GridCells.Adaptive(350.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + ) { + item { + PresentedItem(text = "Percentage dial") { + Dial( + value = sliderValue, + modifier = commonModifier, + animation = ChartDisplayAnimation.Disabled, + colors = DialChartColors( + progressBarBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), + gridScaleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), + progressBarColor = DialProgressColors.GradientWithStops( + listOf( + 0.5f to Color.Red, + 1f to Color.Green, + ) + ) + ), + config = DialConfig( + thickness = 20.dp, + roundCorners = true, + joinStyle = DialJoinStyle.Overlapped, + ), + minAndMaxValueLabel = null, + mainLabel = { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "$it%", + style = MaterialTheme.typography.h4, + color = AppTheme.colors.yellow + ) + Text( + text = "of people like numbers", + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(vertical = AppTheme.dimens.grid_2) + ) + } + }, + indicator = { + Image( + painter = rememberVectorPainter(image = Arrow), + contentDescription = null, + modifier = Modifier + .padding( + start = 20.dp, + ) + .rotate(-90f) + ) + }, + ) + } + } + + item { + PresentedItem(text = "Non-linear scale, simple gradient") { + Dial( + value = sliderValue, + modifier = commonModifier, + animation = ChartDisplayAnimation.Disabled, + colors = DialChartColors( + progressBarBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), + gridScaleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), + progressBarColor = DialProgressColors.Gradient( + listOf( + Color.Red, + Color.Green, + ) + ) + ), + config = DialConfig( + thickness = 20.dp, + roundCorners = true, + joinStyle = DialJoinStyle.WithDegreeGap(15f), + ), + progression = Progression.NonLinear( + anchorPoints = listOf( + AnchorPoint(0f, 0f), + AnchorPoint(20f, 0.1f), + AnchorPoint(40f, 0.2f), + AnchorPoint(60f, 0.3f), + AnchorPoint(80f, 0.8f), + AnchorPoint(100f, 0.9f), + AnchorPoint(120f, 1f), + ) + ), + scaleConfig = ScaleConfig.NonLinearProgressionConfig( + scalePadding = 16.dp, + scaleLineLength = 10.dp, + scaleLabelLayout = { + Text( + text = it.toString(), + modifier = Modifier + .padding(16.dp) + ) + }, + markType = MarkType.Dot, + ), + mainLabel = { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "$it%", + style = MaterialTheme.typography.h4, + color = AppTheme.colors.yellow + ) + Text( + text = "of people like numbers", + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(vertical = AppTheme.dimens.grid_2) + ) + } + }, + indicator = { + Box( + modifier = Modifier + .background(Color.Red) + .fillMaxWidth() + .height(1.dp) + ) + }, + minAndMaxValueLabel = null, + minValue = Float.MIN_VALUE, + maxValue = Float.MAX_VALUE, + ) + } + } - PercentageDial( - percentage = 69, - modifier = Modifier - .fillMaxWidth(), - animation = ChartAnimation.Simple { - spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow + item { + PresentedItem(text = "Non-linear scale, advanced gradient") { + Dial( + value = sliderValue, + modifier = commonModifier, + animation = ChartDisplayAnimation.Disabled, + colors = DialChartColors( + progressBarBackgroundColor = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), + gridScaleColor = MaterialTheme.colors.onSurface.copy(alpha = 0.4f), + progressBarColor = DialProgressColors.GradientWithStops( + listOf( + 0f to Color.Red, + 0.1f to Color.Green, + 0.2f to Color.Blue, + 0.3f to Color.Yellow, + 0.8f to Color.White, + 0.9f to Color.Magenta, + 1f to Color.Cyan, + ) + ) + ), + config = DialConfig( + thickness = 20.dp, + roundCorners = true, + joinStyle = DialJoinStyle.Joined, + ), + progression = Progression.NonLinear( + anchorPoints = listOf( + AnchorPoint(0f, 0f), + AnchorPoint(20f, 0.1f), + AnchorPoint(40f, 0.2f), + AnchorPoint(60f, 0.3f), + AnchorPoint(80f, 0.8f), + AnchorPoint(100f, 0.9f), + AnchorPoint(120f, 1f), + ) + ), + scaleConfig = ScaleConfig.NonLinearProgressionConfig( + scalePadding = 16.dp, + scaleLineLength = 10.dp, + scaleLabelLayout = { + Text( + text = it.toString(), + modifier = Modifier + .padding(16.dp) + ) + }, + markType = MarkType.Dot, + ), + mainLabel = { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "$it%", + style = MaterialTheme.typography.h4, + color = AppTheme.colors.yellow + ) + Text( + text = "of people like numbers", + style = MaterialTheme.typography.body2, + modifier = Modifier.padding(vertical = AppTheme.dimens.grid_2) + ) + } + }, + indicator = { + Box( + modifier = Modifier + .background(Color.Red) + .fillMaxWidth() + .height(1.dp) + ) + }, + minAndMaxValueLabel = null, + minValue = Float.MIN_VALUE, + maxValue = Float.MAX_VALUE, ) - }, - config = DialConfig( - thickness = 20.dp, - roundCorners = true, - ), - mainLabel = { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "$it%", - style = MaterialTheme.typography.h4, - color = AppTheme.colors.yellow - ) - Text( - text = "of people like numbers", - style = MaterialTheme.typography.body2, - modifier = Modifier.padding(vertical = AppTheme.dimens.grid_2) - ) - } } - ) + } - HorizontalDivider() + item { + PresentedItem("No indicator, bigger angle") { + Dial( + value = sliderValue, + minValue = -50f, + maxValue = 150f, + modifier = commonModifier, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + thickness = 20.dp, + roundCorners = true, + joinStyle = DialJoinStyle.Overlapped, + fullAngleInDegrees = 260f, + ), + mainLabel = { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "$it°C", + style = MaterialTheme.typography.h4, + color = AppTheme.colors.yellow + ) + } + }, + scaleConfig = ScaleConfig.LinearProgressionConfig( + smallMarkStep = 10f, + bigMarkStep = 50f, + scaleLabelLayout = { + Text( + text = it.toString(), + modifier = Modifier + .padding(10.dp) + ) + } + ), + ) + } + } - TitleText(text = "Custom ranged dial") + item { + PresentedItem(text = "Non-round corners") { + Dial( + modifier = commonModifier, + value = sliderValue, + minValue = -50f, + maxValue = 150f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + thickness = 30.dp, + ), + mainLabel = { + Text( + text = "$it°C", + style = MaterialTheme.typography.h4, + color = AppTheme.colors.yellow, + ) + } + ) + } + } - Dial( - modifier = Modifier - .fillMaxWidth(), - value = 17, - minValue = -20, - maxValue = 50, - animation = ChartAnimation.Simple { - spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow + item { + PresentedItem(text = "No scale") { + Dial( + modifier = commonModifier, + value = sliderValue, + minValue = -50f, + maxValue = 150f, + animation = ChartDisplayAnimation.Disabled, + config = DialConfig( + thickness = 30.dp, + ), + mainLabel = { + Text( + text = "$it°C", + style = MaterialTheme.typography.h4, + color = AppTheme.colors.yellow, + ) + }, + scaleConfig = null, ) - }, - config = DialConfig( - thickness = 30.dp, - ), - mainLabel = { + } + } + + item( + span = { GridItemSpan(Int.MAX_VALUE) } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { Text( - text = "$it°C", - style = MaterialTheme.typography.h4, - color = AppTheme.colors.yellow + text = "Set the value for dials above", + ) + Slider( + value = sliderValue, + onValueChange = { + sliderValue = it.roundToInt().toFloat() + }, + valueRange = -50f..150f, + steps = 101, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } + } + + item { + PresentedItem(text = "Animated") { + Dial( + modifier = commonModifier, + value = 6f, + minValue = 0f, + maxValue = 10f, + animation = ChartDisplayAnimation.Simple { + spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ) + }, + config = DialConfig( + thickness = 30.dp, + ), + mainLabel = { + Text( + text = "$it°C", + style = MaterialTheme.typography.h4, + color = AppTheme.colors.yellow + ) + } ) } - ) + } } } } diff --git a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/GasBottleScreen.kt b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/GasBottleScreen.kt index ef82d05..ae62498 100644 --- a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/GasBottleScreen.kt +++ b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/GasBottleScreen.kt @@ -11,9 +11,9 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.GridCells -import androidx.compose.foundation.lazy.GridItemSpan -import androidx.compose.foundation.lazy.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -22,7 +22,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.netguru.multiplatform.charts.ChartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation import com.netguru.multiplatform.charts.application.SpacedColumn import com.netguru.multiplatform.charts.application.TitleText import com.netguru.multiplatform.charts.common.AppTheme @@ -51,7 +51,7 @@ fun GasBottleChartScreen() { modifier = Modifier .fillMaxHeight() .fillMaxWidth(), - cells = GridCells.Fixed(numberOfCols), + columns = GridCells.Fixed(numberOfCols), contentPadding = PaddingValues(AppTheme.dimens.grid_4), ) { item(span = { GridItemSpan(numberOfCols) }) { @@ -78,7 +78,7 @@ fun GasBottleChartScreen() { GasBottle( percentage = 100 * item.value / item.capacity, modifier = Modifier.size(width = 200.dp, height = 300.dp), - animation = ChartAnimation.Simple { + animation = ChartDisplayAnimation.Simple { spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessVeryLow diff --git a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/LineChartScreen.kt b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/LineChartScreen.kt index 464f3a2..9cfe160 100644 --- a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/LineChartScreen.kt +++ b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/LineChartScreen.kt @@ -7,92 +7,298 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.netguru.multiplatform.charts.ChartAnimation +import com.netguru.multiplatform.charts.ChartDisplayAnimation import com.netguru.multiplatform.charts.application.ScrollableScreen import com.netguru.multiplatform.charts.application.SpacedColumn import com.netguru.multiplatform.charts.application.TitleText import com.netguru.multiplatform.charts.common.HorizontalDivider +import com.netguru.multiplatform.charts.grid.YAxisTitleData +import com.netguru.multiplatform.charts.grid.axisscale.y.YAxisScaleDynamic import com.netguru.multiplatform.charts.line.LineChart import com.netguru.multiplatform.charts.line.LineChartData import com.netguru.multiplatform.charts.line.LineChartPoint import com.netguru.multiplatform.charts.line.LineChartSeries -import com.netguru.multiplatform.charts.line.LineChartWithLegend +import com.netguru.multiplatform.charts.line.TooltipConfig +import com.netguru.multiplatform.charts.line.XAxisConfig +import com.netguru.multiplatform.charts.line.YAxisConfig +import com.netguru.multiplatform.charts.toRadians +import com.netguru.multiplatform.charts.vertical import com.soywiz.klock.DateTime import com.soywiz.klock.TimeSpan +import kotlin.math.sin @Composable fun LineChartScreen() { val lineData = remember { LineChartData( - series = (1..3).map { + series = (1..3).map { seriesNumber -> LineChartSeries( - dataName = "data $it", + dataName = "data $seriesNumber", lineColor = listOf( Color(0xFFFFCC00), Color(0xFF00D563), Color(0xFF32ADE6), - )[it - 1], - listOfPoints = (1..10).map { point -> + )[seriesNumber - 1], + listOfPoints = (1..20).map { point -> LineChartPoint( x = DateTime.now().minus(TimeSpan(point * 24 * 60 * 60 * 1000.0)).unixMillisLong, - y = (1..15).random().toFloat(), + y = (1..15).random().toFloat() - 5, ) - } + }, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 10f), 0f).takeIf { seriesNumber == 2 } ) }, + dataUnit = "unit", ) } + val lineDataWithLotsOfPoints = remember { + val now = DateTime.now() + LineChartData( + series = listOf( + LineChartSeries( + dataName = "lots of data", + lineColor = Color(0xFFFFCC00), + listOfPoints = (1..(24 * 14)).filter { it % 8 == 0 }.map { point -> + val sine = sin(point.toFloat().toRadians()) + LineChartPoint( + x = now.minus(TimeSpan(point * 5 * 60 * 1000.0)).unixMillisLong, + y = sine + 1.05f, + ) + }, + ), + LineChartSeries( + dataName = "lots of data 2", + lineColor = Color(0xFF32ADE6), + listOfPoints = (1..(24 * 14)).filter { it % 8 == 0 }.map { point -> + val sine = sin(point.toFloat().toRadians()) + LineChartPoint( + x = now.minus(TimeSpan(point * 5 * 60 * 1000.0)).unixMillisLong, + y = sine, + ) + } + ), + ), + dataUnit = null, + ) + } + + ScrollableScreen { SpacedColumn { TitleText(text = "Line chart") LineChart( - lineChartData = lineData, modifier = Modifier .height(300.dp), - xAxisLabel = { - Text( - fontSize = 12.sp, - text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), - textAlign = TextAlign.Center + data = lineData, +// maxVerticalLines = 25, +// xAxisOptions = XAxisOptions( +// markerLayout = { +// Text( +// fontSize = 12.sp, +// text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), +// textAlign = TextAlign.Center, +// ) +// } +// ), +// overlayOptions = OverlayOptions( +// headerLabel = { it, _ -> +// Text( +// text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), +// style = MaterialTheme.typography.overline +// ) +// } +// ), +// animation = ChartAnimation.Sequenced(), +// drawPoints = true, +// legendOptions = null, + yAxisConfig = YAxisConfig( + scale = YAxisScaleDynamic( + chartData = lineData, + maxNumberOfHorizontalLines = 5, + roundMarkersToMultiplicationOf = 2f, ) - }, - overlayHeaderLabel = { - Text( - text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), - style = MaterialTheme.typography.overline - ) - }, - animation = ChartAnimation.Sequenced() + ) ) HorizontalDivider() TitleText(text = "Line chart with legend") - LineChartWithLegend( + LineChart( + modifier = Modifier + .height(300.dp), + data = lineData, + xAxisConfig = XAxisConfig( + markerLayout = { + Text( + fontSize = 12.sp, + text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), + textAlign = TextAlign.Center + ) + }, + maxVerticalLines = 5, + ), + tooltipConfig = TooltipConfig( + headerLabel = { it, _ -> + Text( + text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), + style = MaterialTheme.typography.overline + ) + } + ), + displayAnimation = ChartDisplayAnimation.Sequenced() + ) + + HorizontalDivider() + + TitleText(text = "Line chart with only one data point") + val data = LineChartData( + series = listOf( + LineChartSeries( + dataName = "data 1", + lineColor = Color(0xFFFFCC00), + listOfPoints = listOf( + LineChartPoint( + x = DateTime.now().unixMillisLong, + y = 18f, + ), + ) + ), + ), + dataUnit = "unit", + ) + LineChart( modifier = Modifier .height(300.dp), - lineChartData = lineData, - maxVerticalLines = 5, - xAxisLabel = { - Text( - fontSize = 12.sp, - text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), - textAlign = TextAlign.Center + data = data, + yAxisConfig = YAxisConfig( + scale = YAxisScaleDynamic( + chartData = data, + roundMarkersToMultiplicationOf = 1f, ) - }, - overlayHeaderLabel = { - Text( - text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), - style = MaterialTheme.typography.overline + ), + xAxisConfig = XAxisConfig( + markerLayout = { + Text( + fontSize = 12.sp, + text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), + textAlign = TextAlign.Center + ) + }, + maxVerticalLines = 5, + ), + tooltipConfig = TooltipConfig( + headerLabel = { it, _ -> + Text( + text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), + style = MaterialTheme.typography.overline + ) + } + ), + displayAnimation = ChartDisplayAnimation.Sequenced(), + legendConfig = null, + ) + + TitleText(text = "Line chart with only two data points, both with the same value, and null between them") + val data1 = LineChartData( + series = listOf( + LineChartSeries( + dataName = "data 1", + lineColor = Color(0xFFFFCC00), + listOfPoints = listOf( + LineChartPoint(x = 1660600800000, y = 36.0f), + LineChartPoint(x = 1660687200000, y = null), + LineChartPoint(x = 1660773600000, y = 76.5f), + LineChartPoint(x = 1660860000000, y = 83.7f), + LineChartPoint(x = 1660946400000, y = null), + LineChartPoint(x = 1661032800000, y = null), + LineChartPoint(x = 1661119200000, y = 216.0f) + ) + ), + ), + dataUnit = "unit", + ) + LineChart( + modifier = Modifier + .height(300.dp), + data = data1, + yAxisConfig = YAxisConfig( + scale = YAxisScaleDynamic( + chartData = data1, + roundMarkersToMultiplicationOf = 1f, ) - }, - animation = ChartAnimation.Sequenced() + ), + xAxisConfig = XAxisConfig( + markerLayout = { + Text( + fontSize = 12.sp, + text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), + textAlign = TextAlign.Center + ) + }, + maxVerticalLines = 10, + ), + tooltipConfig = TooltipConfig( + headerLabel = { it, _ -> + Text( + text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), + style = MaterialTheme.typography.overline + ) + } + ), + displayAnimation = ChartDisplayAnimation.Sequenced(), + legendConfig = null, + shouldDrawValueDots = true, + ) + + TitleText(text = "Line chart with legend and with lots of data") + LineChart( + modifier = Modifier + .height(300.dp), + data = lineDataWithLotsOfPoints, + yAxisConfig = YAxisConfig( + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = lineDataWithLotsOfPoints.series.first().dataName, + color = lineDataWithLotsOfPoints.series.first().lineColor, + modifier = Modifier + .vertical() + ) + }, + labelPosition = YAxisTitleData.LabelPosition.Right, + ), + scale = YAxisScaleDynamic( + chartData = lineDataWithLotsOfPoints, + roundMarkersToMultiplicationOf = 0.1f + ) + ), + xAxisConfig = XAxisConfig( + markerLayout = { + Text( + fontSize = 12.sp, + text = DateTime.fromUnix(it as Long).format("HH:mm"), + textAlign = TextAlign.Center + ) + }, + maxVerticalLines = 5, + ), + tooltipConfig = TooltipConfig( + headerLabel = { it, _ -> + Text( + text = DateTime.fromUnix(it as Long).format("HH:mm"), + style = MaterialTheme.typography.overline + ) + } + ), + displayAnimation = ChartDisplayAnimation.Sequenced(), + shouldDrawValueDots = true, ) } } diff --git a/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/LineChartWithTwoYAxisScreen.kt b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/LineChartWithTwoYAxisScreen.kt new file mode 100644 index 0000000..f373e13 --- /dev/null +++ b/example-app/application/src/commonMain/kotlin/com/netguru/multiplatform/charts/application/screen/LineChartWithTwoYAxisScreen.kt @@ -0,0 +1,344 @@ +package com.netguru.multiplatform.charts.application.screen + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.netguru.multiplatform.charts.ChartDisplayAnimation +import com.netguru.multiplatform.charts.application.ScrollableScreen +import com.netguru.multiplatform.charts.application.SpacedColumn +import com.netguru.multiplatform.charts.application.TitleText +import com.netguru.multiplatform.charts.common.HorizontalDivider +import com.netguru.multiplatform.charts.grid.YAxisTitleData +import com.netguru.multiplatform.charts.grid.axisscale.y.YAxisScaleDynamic +import com.netguru.multiplatform.charts.line.LineChartData +import com.netguru.multiplatform.charts.line.LineChartPoint +import com.netguru.multiplatform.charts.line.LineChartSeries +import com.netguru.multiplatform.charts.line.LineChartWithTwoYAxisSets +import com.netguru.multiplatform.charts.line.TooltipConfig +import com.netguru.multiplatform.charts.line.XAxisConfig +import com.netguru.multiplatform.charts.line.YAxisConfig +import com.netguru.multiplatform.charts.vertical +import com.soywiz.klock.DateTime +import com.soywiz.klock.TimeSpan + +@Composable +fun LineChartWithTwoYAxisScreen() { + + val colorLeft = Color(0xFFFFCC00) + val colorRight = Color(0xFF9D78E6) + + val now = DateTime.now() + val lineDataLeft = remember { + LineChartData( + series = listOf( + LineChartSeries( + dataName = "data left", + lineColor = colorLeft, + listOfPoints = (1..30).map { point -> + LineChartPoint( + x = now.minus(TimeSpan(point * 24 * 60 * 60 * 1000.0)).unixMillisLong, + y = if (point in 15..20 && point != 17) null else point.toFloat() + ) + }, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 10f), 0f), + ), +// LineChartSeries( +// dataName = "lots of data 2", +// lineColor = colorLeft, +// listOfPoints = (1..(24 * 14)).filter { it % 8 == 0 }.map { point -> +// val sine = sin(point.toFloat() * PI / 180).toFloat() +// LineChartPoint( +// x = now.minus(TimeSpan(point * 5 * 60 * 1000.0)).unixMillisLong, +// y = sine, +// ) +// } +// ), + ), + dataUnit = "leftUnit", + ) + } + + val lineDataRight = remember { + LineChartData( + series = listOf( + LineChartSeries( + dataName = "data right", + dataNameShort = "short name", + lineColor = colorRight, + listOfPoints = (1..15).map { point -> + LineChartPoint( + x = now.minus(TimeSpan(point * 24 * 60 * 60 * 1000.0)).unixMillisLong, + y = if (point == 3 || point == 5) null else (10 - (point * point)).toFloat() + ) + } + ), +// LineChartSeries( +// dataName = "lots of data", +// lineColor = colorRight, +// listOfPoints = (1..(24 * 14)).filter { it % 8 == 0 }.map { point -> +// val sine = sin(point.toFloat() * PI / 180).toFloat() +// LineChartPoint( +// x = now.minus(TimeSpan(point * 5 * 60 * 1000.0)).unixMillisLong, +// y = sine + 1.05f, +// ) +// } +// ), + ), + dataUnit = null, + ) + } + + val yAxisLabelLeft: @Composable (value: Any) -> Unit = { value -> + Text( + modifier = Modifier + .fillMaxWidth(), + fontSize = 12.sp, + text = value.toString(), + textAlign = TextAlign.End, + color = colorLeft, + ) + } + + val yAxisLabelRight: @Composable (value: Any) -> Unit = { value -> + Text( + modifier = Modifier + .fillMaxWidth(), + fontSize = 12.sp, + text = value.toString(), + textAlign = TextAlign.Start, + color = colorRight, + ) + } + + ScrollableScreen { + SpacedColumn { + + TitleText(text = "Line chart with two Y axis") + LineChartWithTwoYAxisSets( + leftYAxisData = lineDataLeft, + leftYAxisConfig = YAxisConfig( + markerLayout = { + yAxisLabelLeft(it.toString()) + }, + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = lineDataLeft.series.first().dataName, + color = colorLeft, + modifier = Modifier + .vertical() + ) + }, + labelPosition = YAxisTitleData.LabelPosition.Left, + ), + scale = YAxisScaleDynamic( + chartData = lineDataLeft, + roundMarkersToMultiplicationOf = null, + forceShowingValueZeroLine = false, + ), + ), + rightYAxisData = null,//lineDataRight, + rightYAxisConfig = YAxisConfig( + markerLayout = { + yAxisLabelRight(it.toString()) + }, + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = lineDataRight.series.first().dataName, + color = colorRight, + modifier = Modifier + .vertical() + ) + }, + labelPosition = YAxisTitleData.LabelPosition.Right, + ), + scale = YAxisScaleDynamic( + chartData = lineDataRight, + roundMarkersToMultiplicationOf = 0.1f, + forceShowingValueZeroLine = false, + ), + ), + modifier = Modifier + .height(300.dp), + xAxisConfig = XAxisConfig( + markerLayout = { + Text( + fontSize = 12.sp, + text = DateTime.fromUnix(it as Long).format("HH:mm"), + textAlign = TextAlign.Center + ) + } + ), + tooltipConfig = TooltipConfig( + headerLabel = { it, _ -> + Text( + text = DateTime.fromUnix(it as Long).format("HH:mm"), + style = MaterialTheme.typography.overline + ) + }, + width = null, + showInterpolatedValues = false, + ), + displayAnimation = ChartDisplayAnimation.Sequenced(), + shouldDrawValueDots = true, + legendConfig = null, + shouldInterpolateOverNullValues = false, + ) + + HorizontalDivider() + + + TitleText(text = "Line chart with two Y axis") + LineChartWithTwoYAxisSets( + leftYAxisData = lineDataLeft, + leftYAxisConfig = YAxisConfig( + markerLayout = { + yAxisLabelLeft(it.toString()) + }, + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = lineDataLeft.series.first().dataName, + color = colorLeft, +// modifier = Modifier +// .vertical() + ) + }, + labelPosition = YAxisTitleData.LabelPosition.Top, + ), + scale = YAxisScaleDynamic( + chartData = lineDataLeft, + roundMarkersToMultiplicationOf = 1f, + ), + ), + rightYAxisData = lineDataRight, + rightYAxisConfig = YAxisConfig( + markerLayout = { + yAxisLabelRight(it.toString()) + }, + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = lineDataRight.series.first().dataName, + color = colorRight, +// modifier = Modifier +// .vertical() + ) + }, + labelPosition = YAxisTitleData.LabelPosition.Top, +// labelPosition = YAxisTitleData.LabelPosition.Right, + ), + scale = YAxisScaleDynamic( + chartData = lineDataRight, + roundMarkersToMultiplicationOf = 0.1f, + ), + ), + modifier = Modifier + .height(300.dp), + xAxisConfig = XAxisConfig( + markerLayout = { + Text( + fontSize = 12.sp, + text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), + textAlign = TextAlign.Center + ) + } + ), + tooltipConfig = TooltipConfig( + headerLabel = { it, _ -> + Text( + text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), + style = MaterialTheme.typography.overline + ) + }, + showInterpolatedValues = false, + showEnlargedPointOnLine = true, + ), + displayAnimation = ChartDisplayAnimation.Sequenced(), + shouldDrawValueDots = true, + legendConfig = null, + shouldInterpolateOverNullValues = false, + ) + + HorizontalDivider() + + TitleText(text = "Line chart with two Y axis with legend") + LineChartWithTwoYAxisSets( + modifier = Modifier + .height(300.dp), + leftYAxisData = lineDataLeft, + leftYAxisConfig = YAxisConfig( + markerLayout = { + yAxisLabelLeft(it.toString()) + }, + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = lineDataLeft.series.first().dataName, + color = colorLeft, + modifier = Modifier + .vertical() + ) + }, + labelPosition = YAxisTitleData.LabelPosition.Right, + ), + scale = YAxisScaleDynamic( + chartData = lineDataLeft, + roundMarkersToMultiplicationOf = 1f, + ), + ), + rightYAxisData = lineDataRight, + rightYAxisConfig = YAxisConfig( + markerLayout = { + yAxisLabelRight(it.toString()) + }, + yAxisTitleData = YAxisTitleData( + labelLayout = { + Text( + text = lineDataRight.series.first().dataName, + color = colorRight, + modifier = Modifier + .vertical() + ) + }, + labelPosition = YAxisTitleData.LabelPosition.Right, + ), + scale = YAxisScaleDynamic( + chartData = lineDataRight, + roundMarkersToMultiplicationOf = 1f, + ), + ), + xAxisConfig = XAxisConfig( + markerLayout = { + Text( + fontSize = 12.sp, + text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), + textAlign = TextAlign.Center + ) + }, + maxVerticalLines = 5, + ), + tooltipConfig = TooltipConfig( + headerLabel = { it, _ -> + Text( + text = DateTime.fromUnix(it as Long).format("yyyy-MM-dd"), + style = MaterialTheme.typography.overline + ) + } + ), + displayAnimation = ChartDisplayAnimation.Sequenced(), + shouldDrawValueDots = true, + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9dfc35d..1852801 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] ## SDK Versions -compileSdk = "31" +compileSdk = "33" minSdk = "24" -targetSdk = "31" +targetSdk = "33" versionCode = "1" versionName = "0.1" applicationId = "com.netguru.multiplatform.charts" @@ -17,9 +17,10 @@ desktop-packageVersion = "1.0.0" desktop-packageName = "jvm" # Dependencies -kotlin-gradle-plugin = "1.6.10" -android-gradle-plugin = "7.2.1" -compose = "1.1.1" +kotlin-gradle-plugin = "1.7.20" +android-gradle-plugin = "7.3.0" +#compose = "1.2.1" +compose = "1.3.0-beta03" activity-compose = "1.4.0" coroutines = "1.6.0" appcompat = "1.4.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 2e6e589..41dfb87 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists