diff --git a/KMMYCharts/src/androidMain/kotlin/co/yml/kmm/charts/StartScreen.kt b/KMMYCharts/src/androidMain/kotlin/co/yml/kmm/charts/StartScreen.kt index df2e9df..b9303a0 100644 --- a/KMMYCharts/src/androidMain/kotlin/co/yml/kmm/charts/StartScreen.kt +++ b/KMMYCharts/src/androidMain/kotlin/co/yml/kmm/charts/StartScreen.kt @@ -10,6 +10,7 @@ fun ChartScreen(chartType: Int) { 3 -> LineChartScreen() 4 -> PieChartScreen() 5 -> DonutPieChartScreen() - 6-> BubbleChartWithGrid() + 6 -> BubbleChartWithGrid() + 7 -> BarWithLineChart() } } \ No newline at end of file diff --git a/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/CommonMainScreen.kt b/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/CommonMainScreen.kt index 5eb13f1..2a398ea 100644 --- a/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/CommonMainScreen.kt +++ b/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/CommonMainScreen.kt @@ -32,6 +32,8 @@ import co.yml.kmm.charts.ui.barchart.models.GroupBarChartData import co.yml.kmm.charts.ui.barchart.models.GroupSeparatorConfig import co.yml.kmm.charts.ui.bubblechart.BubbleChart import co.yml.kmm.charts.ui.bubblechart.model.BubbleChartData +import co.yml.kmm.charts.ui.combinedchart.CombinedChart +import co.yml.kmm.charts.ui.combinedchart.model.CombinedChartData import co.yml.kmm.charts.ui.linechart.LineChart import co.yml.kmm.charts.ui.linechart.model.* import co.yml.kmm.charts.ui.piechart.charts.DonutPieChart @@ -463,3 +465,68 @@ internal fun BubbleChartWithGrid() { ) } + + +@Composable +internal fun BarWithLineChart() { + val maxRange = 100 + val groupBarData = DataUtils.getGroupBarChartData(50, 100, 3) + val yStepSize = 10 + val xAxisData = AxisData.Builder() + .axisStepSize(30.dp) + .bottomPadding(5.dp) + .labelData { index -> index.toString() } + .build() + val yAxisData = AxisData.Builder() + .steps(yStepSize) + .labelAndAxisLinePadding(20.dp) + .axisOffset(20.dp) + .labelData { index -> (index * (maxRange / yStepSize)).toString() } + .build() + val linePlotData = LinePlotData( + lines = listOf( + Line( + DataUtils.getLineChartData(50, maxRange = 100), + lineStyle = LineStyle(color = Color.Blue), + intersectionPoint = IntersectionPoint(), + selectionHighlightPoint = SelectionHighlightPoint(), + selectionHighlightPopUp = SelectionHighlightPopUp() + ), + Line( + DataUtils.getLineChartData(50, maxRange = 100), + lineStyle = LineStyle(color = Color.Black), + intersectionPoint = IntersectionPoint(), + selectionHighlightPoint = SelectionHighlightPoint(), + selectionHighlightPopUp = SelectionHighlightPopUp() + ) + ) + ) + val colorPaletteList = DataUtils.getColorPaletteList(3) + val legendsConfig = LegendsConfig( + legendLabelList = DataUtils.getLegendsLabelData(colorPaletteList), + gridColumnCount = 3 + ) + val barPlotData = BarPlotData( + groupBarList = groupBarData, + barStyle = BarStyle(barWidth = 35.dp), + barColorPaletteList = colorPaletteList + ) + val combinedChartData = CombinedChartData( + combinedPlotDataList = listOf(barPlotData, linePlotData), + xAxisData = xAxisData, + yAxisData = yAxisData + ) + Column( + Modifier + .height(500.dp) + ) { + CombinedChart( + modifier = Modifier + .height(400.dp), + combinedChartData = combinedChartData + ) + Legends( + legendsConfig = legendsConfig + ) + } +} \ No newline at end of file diff --git a/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/common/utils/DataUtils.kt b/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/common/utils/DataUtils.kt index 535dfd4..23c936f 100644 --- a/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/common/utils/DataUtils.kt +++ b/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/common/utils/DataUtils.kt @@ -164,6 +164,26 @@ object DataUtils { return list } + + /** + * Returns list of points + * @param listSize: Size of total number of points needed. + * @param start: X values to start from. ex: 50 to 100 + * @param maxRange: Max range of Y values + */ + fun getLineChartData(listSize: Int, start: Int = 0, maxRange: Int): List { + val list = arrayListOf() + for (index in 0 until listSize) { + list.add( + Point( + index.toFloat(), + (start until maxRange).random().toFloat() + ) + ) + } + return list + } + } diff --git a/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/ui/combinedchart/CombinedChart.kt b/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/ui/combinedchart/CombinedChart.kt new file mode 100644 index 0000000..88eb7ff --- /dev/null +++ b/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/ui/combinedchart/CombinedChart.kt @@ -0,0 +1,378 @@ +@file:OptIn(ExperimentalMaterialApi::class, ExperimentalTextApi::class) + +package co.yml.kmm.charts.ui.combinedchart + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import co.yml.kmm.charts.axis.XAxis +import co.yml.kmm.charts.axis.YAxis +import co.yml.kmm.charts.chartcontainer.container.ScrollableCanvasContainer +import co.yml.kmm.charts.common.extensions.RowClip +import co.yml.kmm.charts.common.extensions.getMaxElementInYAxis +import co.yml.kmm.charts.common.extensions.isNotNull +import co.yml.kmm.charts.common.extensions.isPointTapped +import co.yml.kmm.charts.common.extensions.isTapped +import co.yml.kmm.charts.common.model.PlotData +import co.yml.kmm.charts.common.model.PlotType +import co.yml.kmm.charts.common.model.Point +import co.yml.kmm.charts.ui.barchart.drawUnderScrollMask +import co.yml.kmm.charts.ui.barchart.getGroupBarDrawOffset +import co.yml.kmm.charts.ui.barchart.highlightGroupBar +import co.yml.kmm.charts.ui.barchart.models.BarData +import co.yml.kmm.charts.ui.barchart.models.BarPlotData +import co.yml.kmm.charts.ui.combinedchart.model.CombinedChartData +import co.yml.kmm.charts.ui.linechart.drawHighLightOnSelectedPoint +import co.yml.kmm.charts.ui.linechart.drawHighlightText +import co.yml.kmm.charts.ui.linechart.drawShadowUnderLineAndIntersectionPoint +import co.yml.kmm.charts.ui.linechart.drawStraightOrCubicLine +import co.yml.kmm.charts.ui.linechart.getCubicPoints +import co.yml.kmm.charts.ui.linechart.getMappingPointsToGraph +import co.yml.kmm.charts.ui.linechart.getMaxScrollDistance +import co.yml.kmm.charts.ui.linechart.model.LinePlotData +import kotlinx.coroutines.launch + +/** + * + * CombinedChart compose method for drawing combined line and bar charts. + * @param modifier: All modifier related properties + * @param combinedChartData : All data needed to draw combined chart. [CombinedChartData] Data + * class to save all params related to combined line and bar chart. + */ +@OptIn(ExperimentalTextApi::class) +@Composable +internal fun CombinedChart(modifier: Modifier, combinedChartData: CombinedChartData) { + val accessibilitySheetState = + rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + val textMeasure = rememberTextMeasurer() + + Surface(modifier) { + with(combinedChartData) { + var xOffset by remember { mutableStateOf(0f) } + var isTapped by remember { mutableStateOf(false) } + var columnWidth by remember { mutableStateOf(0f) } + var rowHeight by remember { mutableStateOf(0f) } + val paddingRight = paddingEnd + val linePlotData: LinePlotData = + getDataFromType(combinedPlotDataList, PlotType.Line) as? LinePlotData + ?: LinePlotData.default() + val barPlotData: BarPlotData = + getDataFromType(combinedPlotDataList, PlotType.Bar) as? BarPlotData + ?: BarPlotData.default() + val linePoints: List = + linePlotData.lines.flatMap { line -> line.dataPoints.map { it } } + val barPoints = barPlotData.groupBarList.flatMap { bar -> bar.barList.map { it } } + val bgColor = MaterialTheme.colors.surface + val xMin = + minOf(if(linePoints.isEmpty()) 0.0f else linePoints.minOf { it.x }, (barPlotData.groupBarList.size).toFloat()) + val xMax = + maxOf(if(linePoints.isEmpty()) 0.0f else linePoints.maxOf { it.x }, (barPlotData.groupBarList.size).toFloat()) + val yMin = minOf(if(linePoints.isEmpty()) 0.0f else linePoints.minOf { it.y }, if(barPoints.isEmpty())0.0f else barPoints.minOf { it.point.y }) + val yMax = maxOf(if(linePoints.isEmpty()) 0.0f else linePoints.maxOf { it.y }, if(barPoints.isEmpty())0.0f else barPoints.maxOf { it.point.y }) + val requiredSteps = + maxOf( + if(linePlotData.lines.isEmpty()) 0 else linePlotData.lines.map { it.dataPoints.size - 1 }.maxOf { it }, + if(barPlotData.groupBarList.isEmpty()) 0 else barPlotData.groupBarList.size + ) + val xAxisData = xAxisData.copy( + axisStepSize = if(barPlotData.groupBarList.isEmpty()) 30.dp else((barPlotData.barStyle.barWidth * barPlotData.groupingSize) + + barPlotData.barStyle.paddingBetweenBars), + steps = requiredSteps, + startDrawPadding = LocalDensity.current.run { columnWidth.toDp() }, + shouldDrawAxisLineTillEnd = true + ) + val yAxisData = + yAxisData.copy( + axisBottomPadding = LocalDensity.current.run { rowHeight.toDp() }, + axisTopPadding = paddingTop + ) + val maxElementInYAxis = getMaxElementInYAxis(yMax, yAxisData.steps) + var identifiedBarPoint by remember { mutableStateOf(BarData(Point(0f, 0f))) } + var identifiedPoint by remember { mutableStateOf(Point(0f, 0f)) } + var tapOffset by remember { mutableStateOf(Offset(0f, 0f)) } + + ScrollableCanvasContainer( + modifier = modifier + .semantics { + contentDescription = accessibilityConfig.chartDescription + }, + containerBackgroundColor = backgroundColor, + isPinchZoomEnabled = isZoomAllowed, + calculateMaxDistance = { xZoom -> + xOffset = + ((barPlotData.barStyle.barWidth.toPx() * barPlotData.groupingSize) + + barPlotData.barStyle.paddingBetweenBars.toPx()) * xZoom + getMaxScrollDistance( + columnWidth, + xMax, + xMin, + xOffset, + 0f, + paddingRight.toPx(), + size.width + ) + }, + drawXAndYAxis = { scrollOffset, xZoom -> + val axisPoints = mutableListOf() + for (index in 0 until xMax.toInt()) { + axisPoints.add(Point(index.toFloat(), 0f)) + } + XAxis( + xAxisData = xAxisData, + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .wrapContentHeight() + .clip( + RowClip( + columnWidth, + paddingRight + ) + ) + .onGloballyPositioned { + rowHeight = it.size.height.toFloat() + }, + xStart = columnWidth + LocalDensity.current.run { + (barPlotData.barStyle.barWidth.toPx() * barPlotData.groupingSize) / 2 + horizontalExtraSpace.toPx() + }, + scrollOffset = scrollOffset, + zoomScale = xZoom, + chartData = if(barPoints.isEmpty()) linePoints else axisPoints, + axisStart = columnWidth + ) + YAxis( + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxHeight() + .wrapContentWidth() + .onGloballyPositioned { + columnWidth = it.size.width.toFloat() + }, + yAxisData = yAxisData + ) + }, + onDraw = { scrollOffset, xZoom -> + val yBottom = size.height - rowHeight + val yOffset = + ((yBottom - yAxisData.axisTopPadding.toPx()) / maxElementInYAxis) + xOffset = if(barPoints.isEmpty())xAxisData.axisStepSize.toPx() * xZoom else + ((barPlotData.barStyle.barWidth.toPx() * barPlotData.groupingSize) + + barPlotData.barStyle.paddingBetweenBars.toPx()) * xZoom + val xLeft = + columnWidth + horizontalExtraSpace.toPx() + val barTapLocks = mutableMapOf>() + val linePointLocks = mutableMapOf>() + + for (plotData in combinedPlotDataList) { + when (plotData) { + is LinePlotData -> { + // Draw line chart + val xStartPosition = + columnWidth + horizontalExtraSpace.toPx() + + ((barPlotData.barStyle.barWidth.toPx() * barPlotData.groupingSize) / 2) + plotData.lines.forEach { line -> + val pointsData = getMappingPointsToGraph( + line.dataPoints, + xMin, + xOffset, + xStartPosition, + scrollOffset, + yBottom, + yMin, + yOffset + ) + val (cubicPoints1, cubicPoints2) = getCubicPoints(pointsData) + + // Draw cubic line using the points and form a line graph + val cubicPath = + drawStraightOrCubicLine( + pointsData, + cubicPoints1, + cubicPoints2, + line.lineStyle + ) + + // Draw area under curve + drawShadowUnderLineAndIntersectionPoint( + cubicPath, + pointsData, + yBottom, + line + ) + + pointsData.forEachIndexed { index, point -> + if (isTapped && point.isPointTapped( + tapOffset, + tapPadding.toPx() + ) + ) { + // Dealing with only one line graph hence tapPointLocks[0] + linePointLocks[0] = line.dataPoints[index] to point + } + } + if (isTapped && linePointLocks.isNotEmpty()) { + drawHighLightOnSelectedPoint( + linePointLocks, + columnWidth, + paddingRight, + yBottom, + line.selectionHighlightPoint?.copy( + isHighlightLineRequired = false + ) + ) + if (line.selectionHighlightPopUp != null) { + val x = + linePointLocks.values.firstOrNull()?.second?.x + if (x != null) identifiedPoint = + linePointLocks.values.map { it.first }.first() + val selectedOffset = + linePointLocks.values.firstOrNull()?.second + if (selectedOffset.isNotNull()) { + drawHighlightText( + identifiedPoint, + selectedOffset ?: Offset(0f, 0f), + line.selectionHighlightPopUp, + textMeasure + ) + } + } + } + } + } + is BarPlotData -> { + // Draw bar graph + plotData.groupBarList.forEachIndexed { index, groupBarData -> + var insideOffset = 0f + groupBarData.barList.forEachIndexed { subIndex, individualBar -> + val drawOffset = getGroupBarDrawOffset( + index, individualBar.point.y, xOffset, xLeft, + scrollOffset, yBottom, yOffset, 0f + ) + val height = yBottom - drawOffset.y + + val individualOffset = + Offset(drawOffset.x + insideOffset, drawOffset.y) + + // drawing each individual bars + drawGroupBarGraph( + plotData, + individualOffset, + height, + subIndex + ) + insideOffset += plotData.barStyle.barWidth.toPx() + + val middleOffset = + Offset( + individualOffset.x + plotData.barStyle.barWidth.toPx() / 2, + drawOffset.y + ) + // store the tap points for selection + if (isTapped && middleOffset.isTapped( + tapOffset, + plotData.barStyle.barWidth.toPx(), + yBottom, + tapPadding.toPx() + ) + ) { + barTapLocks[0] = individualBar to individualOffset + } + } + } + } + } + } + if (isTapped && linePointLocks.isEmpty() && + barPlotData.barStyle.selectionHighlightData != null + ) { + // highlighting the selected bar and showing the data points + identifiedBarPoint = highlightGroupBar( + barTapLocks, + true, + identifiedBarPoint, + barPlotData.barStyle.selectionHighlightData, + isTapped, + columnWidth, + yBottom, + paddingRight, + yOffset, + barPlotData.barStyle.barWidth, + textMeasure + ) + } + drawUnderScrollMask(columnWidth, paddingRight, bgColor) + }, + onPointClicked = { offset: Offset, _: Float -> + isTapped = true + tapOffset = offset + }, + onScroll = { + isTapped = false + } + ) + + } + } +} + +/** + * Returns data for given plot type from the combinedPlotDataList + * @param combinedPlotDataList : List of combined plot data + * @param type: Type of plot of with data is to be returned. + */ +private fun getDataFromType(combinedPlotDataList: List, type: PlotType): PlotData? { + return when (type) { + is PlotType.Line -> combinedPlotDataList.filterIsInstance().firstOrNull() + is PlotType.Bar -> combinedPlotDataList.filterIsInstance().firstOrNull() + else -> null // Handle if required in future. + } +} + +/** + * + * Used to draw the individual bars + * @param barPlotData : all meta data related to the bar graph + * @param drawOffset: topLeft offset for the drawing the bar + * @param height : height of the bar graph + * @param subIndex : Index of the bar + */ +private fun DrawScope.drawGroupBarGraph( + barPlotData: BarPlotData, drawOffset: Offset, + height: Float, + subIndex: Int +) { + val color = if (subIndex < barPlotData.barColorPaletteList.size) { + barPlotData.barColorPaletteList[subIndex] + } else Color.Transparent + with(barPlotData.barStyle) { + drawRoundRect( + color = color, + topLeft = drawOffset, + size = Size(barWidth.toPx(), height), + cornerRadius = CornerRadius( + cornerRadius.toPx(), + cornerRadius.toPx() + ), + style = barDrawStyle, + blendMode = barBlendMode + ) + } +} diff --git a/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/ui/combinedchart/model/CombinedChartData.kt b/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/ui/combinedchart/model/CombinedChartData.kt new file mode 100644 index 0000000..69bb9dc --- /dev/null +++ b/KMMYCharts/src/commonMain/kotlin/co/yml/kmm/charts/ui/combinedchart/model/CombinedChartData.kt @@ -0,0 +1,39 @@ +package co.yml.kmm.charts.ui.combinedchart.model + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import co.yml.kmm.charts.axis.AxisData +import co.yml.kmm.charts.common.model.AccessibilityConfig +import co.yml.kmm.charts.common.model.PlotData + +/** + * + * CombinedLineAndBarGraphData data class that contains all params user need to define to draw a bar and line graph. + * @param combinedPlotDataList: Defines list of plot data's to be drawn and order of graph drawing is maintained as + * per the list order. Distinct plot data's are only allowed. + * @param xAxisData: All the configurations related to X-Axis to be defined here in [AxisData] + * @param yAxisData: All the configurations related to Y-Axis to be defined here in [AxisData] + * @param paddingTop: Padding from the top of the canvas to start of the graph container. + * @param bottomPadding: Padding from the bottom of the canvas to bottom of the graph container. + * @param containerPaddingEnd: Container inside padding end after the last point of the graph. + * @param backgroundColor: Background color of the Y & X components., + * @param isZoomAllowed: True if zoom in for all vertical graph components is allowed else false. + * @param tapPadding: Tap padding offset for selected point. + * @param horizontalExtraSpace: Extra padding at the end of the canvas container. + * @param accessibilityConfig: Configs related to accessibility service defined here in [AccessibilityConfig] + */ +data class CombinedChartData( + val combinedPlotDataList: List, + val xAxisData: AxisData = AxisData.Builder().build(), + val yAxisData: AxisData = AxisData.Builder().build(), + val paddingTop: Dp = 30.dp, + val bottomPadding: Dp = 10.dp, + val paddingEnd: Dp = 10.dp, + val horizontalExtraSpace: Dp = 10.dp, + val containerPaddingEnd: Dp = 15.dp, + val backgroundColor: Color = Color.White, + val tapPadding: Dp = 10.dp, + val isZoomAllowed: Boolean = true, + val accessibilityConfig: AccessibilityConfig = AccessibilityConfig() +) diff --git a/KMMYCharts/src/iosMain/kotlin/co/yml/kmm/charts/StartScreenIOS.kt b/KMMYCharts/src/iosMain/kotlin/co/yml/kmm/charts/StartScreenIOS.kt index 9c8bea7..dfdada2 100644 --- a/KMMYCharts/src/iosMain/kotlin/co/yml/kmm/charts/StartScreenIOS.kt +++ b/KMMYCharts/src/iosMain/kotlin/co/yml/kmm/charts/StartScreenIOS.kt @@ -11,5 +11,6 @@ internal fun StartScreenIOS(chartType: Int) { 4 -> PieChartScreen() 5 -> DonutPieChartScreen() 6 -> BubbleChartWithGrid() + 7 -> BarWithLineChart() } } \ No newline at end of file diff --git a/androidApp/src/main/java/co/yml/ycharts/app/presentation/CombinedLineAndBarChartActivity.kt b/androidApp/src/main/java/co/yml/ycharts/app/presentation/CombinedLineAndBarChartActivity.kt index cdc3903..717298b 100644 --- a/androidApp/src/main/java/co/yml/ycharts/app/presentation/CombinedLineAndBarChartActivity.kt +++ b/androidApp/src/main/java/co/yml/ycharts/app/presentation/CombinedLineAndBarChartActivity.kt @@ -38,7 +38,7 @@ class CombinedLineAndBarChartActivity : ComponentActivity() { .padding(it), contentAlignment = Alignment.TopCenter ) { - ChartScreen(chartType = 1) + ChartScreen(chartType = 7) } } } diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 488f7f4..03e371f 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -16,7 +16,8 @@ struct ContentView: View { @State private var isPieChartPresented = false @State private var isDonutChartPresented = false @State private var isBubbleChartPresented = false - + @State private var isCombinedChartPresented = false + @@ -111,7 +112,22 @@ struct ContentView: View { MainView(chartType: 6) .navigationTitle("Bubble Chart") }.frame(maxWidth: .infinity).padding() - + + Button(action: { + self.isCombinedChartPresented = true + }) { + Text("Combined Chart") + .padding() + .frame(maxWidth: .infinity) + .font(.body) + .foregroundColor(.white) + .background(Color.black) + + }.navigationDestination(isPresented: $isCombinedChartPresented) { + MainView(chartType: 7) + .navigationTitle("Combined Chart") + }.frame(maxWidth: .infinity).padding() + } } }