From d609ce53f21b8aed34704be818bf30354a7d6f8b Mon Sep 17 00:00:00 2001 From: abdo-essam Date: Tue, 20 Jan 2026 00:10:00 +0200 Subject: [PATCH 1/2] Refactor: Introduce customizable tooltips for LineChart This commit introduces a major enhancement to the LineChart, allowing for extensive customization of tooltips that appear on data point clicks. The previous hardcoded tooltip has been replaced with a flexible `TooltipConfig` model. Key changes include: - **Added `TooltipConfig`:** A new data class to control tooltip appearance and behavior, including colors, size, text style, and content. - **Customizable Content:** Tooltips can now display Y-values, X-values, both, or fully custom-formatted text. This supports localization and varied data representation. - **Marker Styles:** The circular marker at the selected point can now be a stroke (outline), solid (filled), or disabled entirely. - **Refactored Drawing Logic:** Replaced `CircleWithRectAndText` with a new `TooltipWithMarker` component that handles the new customizable drawing. - **Enabled Multi-Line Tooltips:** Removed the restriction that disabled tooltips on multi-line charts. - **Added Documentation:** Included a new `TOOLTIP_CUSTOMIZATION.md` file and comprehensive usage examples in `LineChartTooltipExamples.kt`. --- .../com/aay/compose/lineChart/ChartContent.kt | 5 +- .../components/CircleWithRectAndText.kt | 80 ---- .../lineChart/components/DefaultLines.kt | 17 +- .../lineChart/components/QuadraticLines.kt | 19 +- .../lineChart/components/TooltipWithMarker.kt | 260 +++++++++++++ .../compose/lineChart/model/LineParameters.kt | 1 + .../compose/lineChart/model/TooltipConfig.kt | 120 ++++++ .../aay/common/LineChartTooltipExamples.kt | 356 ++++++++++++++++++ 8 files changed, 759 insertions(+), 99 deletions(-) delete mode 100644 chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/CircleWithRectAndText.kt create mode 100644 chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/TooltipWithMarker.kt create mode 100644 chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/TooltipConfig.kt create mode 100644 common/src/commonMain/kotlin/com/aay/common/LineChartTooltipExamples.kt diff --git a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/ChartContent.kt b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/ChartContent.kt index 41db76e2..305b170c 100644 --- a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/ChartContent.kt +++ b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/ChartContent.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.* @@ -119,9 +118,7 @@ internal fun ChartContent( } } else { - if (linesParameters.size >= 2) { - clickedPoints.clear() - } + // Tooltips enabled for multi-line charts linesParameters.forEach { line -> if (line.lineType == LineType.DEFAULT_LINE) { diff --git a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/CircleWithRectAndText.kt b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/CircleWithRectAndText.kt deleted file mode 100644 index 646271f4..00000000 --- a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/CircleWithRectAndText.kt +++ /dev/null @@ -1,80 +0,0 @@ -package com.aay.compose.lineChart.components - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.ExperimentalTextApi -import androidx.compose.ui.text.TextMeasurer -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.drawText -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.aay.compose.lineChart.model.LineParameters -import com.aay.compose.utils.formatToThousandsMillionsBillions - -@OptIn(ExperimentalTextApi::class) -internal fun DrawScope.circleWithRectAndText( - animatedProgress: Animatable, - textMeasure: TextMeasurer, - info: Double, - stroke: Stroke, - line: LineParameters, - x: Dp, - y: Double, -) { - chartCircle(x.toPx(), y.toFloat(), line.lineColor, animatedProgress, stroke) - chartRectangleWithText(x, y, line.lineColor, textMeasure, info) -} - - -@OptIn(ExperimentalTextApi::class) -private fun DrawScope.chartRectangleWithText( - x: Dp, y: Double, color: Color, textMeasurer: TextMeasurer, infoText: Double, -) { - val rectSize = Size(50.dp.toPx(), 30.dp.toPx()) - val rectTopLeft = Offset( - x.toPx() - rectSize.width / 1.5.toFloat(), - y.toFloat() - rectSize.height * 1.5.toFloat() - ) - val rectBounds = Rect(rectTopLeft, rectSize) - val text = "Value:${infoText.toFloat().formatToThousandsMillionsBillions()}" - - val textStyle = TextStyle(fontSize = 8.sp, color = Color.Black) - - val textLayoutResult = textMeasurer.measure( - text = AnnotatedString(text), - style = textStyle - ) - - val textOffset = Offset( - rectTopLeft.x + rectSize.width / 2 - textLayoutResult.size.width / 2, - rectTopLeft.y + rectSize.height / 4 + textLayoutResult.size.height / 2 - ) - - drawRoundRect( - color = color, - topLeft = rectBounds.topLeft, - size = rectBounds.size, - cornerRadius = CornerRadius(16.dp.toPx()), - style = Stroke(width = 1.dp.toPx()) - ) - - drawContext.canvas.nativeCanvas.apply { - drawText( - textMeasurer = textMeasurer, - text = text, - style = textStyle, - topLeft = textOffset - ) - } - -} \ No newline at end of file diff --git a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/DefaultLines.kt b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/DefaultLines.kt index e625b302..5c6813f8 100644 --- a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/DefaultLines.kt +++ b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/DefaultLines.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ExperimentalTextApi @@ -31,6 +30,7 @@ internal fun DrawScope.drawDefaultLineWithShadow( clickedPoints: MutableList>, textMeasure: TextMeasurer, xRegionWidth: Dp, + xAxisData: List = emptyList(), ) { val strokePathOfDefaultLine = drawLineAsDefault( @@ -41,7 +41,8 @@ internal fun DrawScope.drawDefaultLineWithShadow( spacingY = spacingY, clickedPoints = clickedPoints, textMeasure = textMeasure, - xRegionWidth = xRegionWidth + xRegionWidth = xRegionWidth, + xAxisData = xAxisData ) if (line.lineShadow) { @@ -71,6 +72,7 @@ private fun DrawScope.drawLineAsDefault( clickedPoints: MutableList>, textMeasure: TextMeasurer, xRegionWidth: Dp, + xAxisData: List = emptyList(), ) = Path().apply { val height = size.height.toDp() drawPathLineWrapper( @@ -100,14 +102,15 @@ private fun DrawScope.drawLineAsDefault( lastClickedPoint = null } else { lastClickedPoint = Pair(startXPoint.toPx(), startYPoint.toFloat()) - circleWithRectAndText( + drawTooltipWithMarker( x = startXPoint, y = startYPoint, - textMeasure = textMeasure, - info = info, - stroke = Stroke(width = 2.dp.toPx()), + textMeasurer = textMeasure, + xIndex = index, + yValue = info, line = lineParameter, - animatedProgress = animatedProgress + animatedProgress = animatedProgress, + xAxisData = xAxisData ) } } diff --git a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/QuadraticLines.kt b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/QuadraticLines.kt index b7816106..f66f1ed5 100644 --- a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/QuadraticLines.kt +++ b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/QuadraticLines.kt @@ -6,7 +6,6 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.clipRect import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ExperimentalTextApi @@ -32,6 +31,7 @@ internal fun DrawScope.drawQuarticLineWithShadow( clickedPoints: MutableList>, xRegionWidth: Dp, textMeasurer: TextMeasurer, + xAxisData: List = emptyList(), ) { val strokePathOfQuadraticLine = drawLineAsQuadratic( line = line, @@ -42,7 +42,8 @@ internal fun DrawScope.drawQuarticLineWithShadow( specialChart = specialChart, clickedPoints = clickedPoints, textMeasurer = textMeasurer, - xRegionWidth = xRegionWidth + xRegionWidth = xRegionWidth, + xAxisData = xAxisData ) if (line.lineShadow && !specialChart) { @@ -73,7 +74,8 @@ fun DrawScope.drawLineAsQuadratic( specialChart: Boolean, clickedPoints: MutableList>, textMeasurer: TextMeasurer, - xRegionWidth: Dp + xRegionWidth: Dp, + xAxisData: List = emptyList(), ) = Path().apply { var medX: Float val height = size.height.toDp() @@ -121,14 +123,15 @@ fun DrawScope.drawLineAsQuadratic( lastClickedPoint = null } else { lastClickedPoint = Pair(xFirstPoint.toPx(), yFirstPoint.toFloat()) - circleWithRectAndText( + drawTooltipWithMarker( x = xFirstPoint, y = yFirstPoint, - textMeasure = textMeasurer, - info = info, - stroke = Stroke(width = 2.dp.toPx()), + textMeasurer = textMeasurer, + xIndex = index, + yValue = info, line = line, - animatedProgress = animatedProgress + animatedProgress = animatedProgress, + xAxisData = xAxisData ) } diff --git a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/TooltipWithMarker.kt b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/TooltipWithMarker.kt new file mode 100644 index 00000000..71064b79 --- /dev/null +++ b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/TooltipWithMarker.kt @@ -0,0 +1,260 @@ +package com.aay.compose.lineChart.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +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.graphics.nativeCanvas +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.aay.compose.lineChart.model.LineParameters +import com.aay.compose.lineChart.model.MarkerStyle +import com.aay.compose.lineChart.model.TooltipContent +import com.aay.compose.lineChart.model.TooltipSize + +/** + * Draws a tooltip with a circular marker at the clicked point on the line chart. + * + * This is the main entry point for rendering tooltips when a user clicks on a data point. + * It draws both the marker circle and the tooltip box with customizable content and styling. + * + * @param animatedProgress Animation state for the chart + * @param textMeasurer Text measurer for calculating text dimensions + * @param xIndex Index of the clicked point in the data array + * @param yValue Y-axis value of the clicked point + * @param line Line parameters containing tooltip configuration + * @param x X-coordinate of the point + * @param y Y-coordinate of the point + * @param xAxisData List of X-axis labels for displaying X values in tooltip + */ +@OptIn(ExperimentalTextApi::class) +internal fun DrawScope.drawTooltipWithMarker( + animatedProgress: Animatable, + textMeasurer: TextMeasurer, + xIndex: Int, + yValue: Double, + line: LineParameters, + x: Dp, + y: Double, + xAxisData: List = emptyList(), +) { + val config = line.tooltipConfig + + if (!config.enabled) return + + // Draw marker circle at the point + drawMarkerCircle( + x = x.toPx(), + y = y.toFloat(), + color = line.lineColor, + animatedProgress = animatedProgress, + markerStyle = config.markerStyle + ) + + // Draw tooltip box with content + drawTooltipBox( + x = x, + y = y, + color = line.lineColor, + textMeasurer = textMeasurer, + xIndex = xIndex, + yValue = yValue, + xAxisData = xAxisData, + config = config + ) +} + +/** + * Draws the circular marker at the clicked point. + * + * @param x X-coordinate of the marker center + * @param y Y-coordinate of the marker center + * @param color Color of the marker + * @param animatedProgress Animation state + * @param markerStyle Style of the marker (Stroke, Solid, or None) + */ +private fun DrawScope.drawMarkerCircle( + x: Float, + y: Float, + color: Color, + animatedProgress: Animatable, + markerStyle: MarkerStyle, +) { + when (markerStyle) { + is MarkerStyle.Stroke -> { + chartCircle( + x = x, + y = y, + color = color, + animatedProgress = animatedProgress, + stroke = Stroke(width = markerStyle.strokeWidth.toPx()) + ) + } + is MarkerStyle.Solid -> { + chartCircle( + x = x, + y = y, + color = color, + animatedProgress = animatedProgress, + stroke = null // null means filled circle + ) + } + is MarkerStyle.None -> { + // Don't draw any marker + } + } +} + +/** + * Draws the tooltip box with formatted content. + * + * @param x X-coordinate of the point + * @param y Y-coordinate of the point + * @param color Default color (line color) used if borderColor is not specified + * @param textMeasurer Text measurer for calculating text dimensions + * @param xIndex Index of the point in the data array + * @param yValue Y-axis value of the point + * @param xAxisData List of X-axis labels + * @param config Tooltip configuration + */ +@OptIn(ExperimentalTextApi::class) +private fun DrawScope.drawTooltipBox( + x: Dp, + y: Double, + color: Color, + textMeasurer: TextMeasurer, + xIndex: Int, + yValue: Double, + xAxisData: List, + config: com.aay.compose.lineChart.model.TooltipConfig, +) { + // Generate tooltip text based on content configuration + val tooltipText = generateTooltipText( + content = config.content, + xIndex = xIndex, + yValue = yValue, + xAxisData = xAxisData + ) + + // Create text style + val textStyle = TextStyle( + fontSize = config.textSize, + color = config.textColor + ) + + // Measure text to determine tooltip size + val textLayoutResult = textMeasurer.measure( + text = AnnotatedString(tooltipText), + style = textStyle + ) + + // Calculate tooltip box size + val tooltipSize = when (config.size) { + is TooltipSize.Auto -> { + // Auto-size based on text with padding + Size( + width = textLayoutResult.size.width + (config.padding.toPx() * 2), + height = textLayoutResult.size.height + (config.padding.toPx() * 2) + ) + } + is TooltipSize.Fixed -> { + Size( + width = config.size.width.toPx(), + height = config.size.height.toPx() + ) + } + } + + // Calculate tooltip position (centered above the point) + val tooltipTopLeft = Offset( + x = x.toPx() - tooltipSize.width / 2f, + y = y.toFloat() - tooltipSize.height - 20.dp.toPx() // 20dp gap above the point + ) + + val tooltipBounds = Rect(tooltipTopLeft, tooltipSize) + + // Draw tooltip background + drawRoundRect( + color = config.backgroundColor, + topLeft = tooltipBounds.topLeft, + size = tooltipBounds.size, + cornerRadius = CornerRadius(config.cornerRadius.toPx()), + style = Fill + ) + + // Draw tooltip border + val borderColor = config.borderColor ?: color + drawRoundRect( + color = borderColor, + topLeft = tooltipBounds.topLeft, + size = tooltipBounds.size, + cornerRadius = CornerRadius(config.cornerRadius.toPx()), + style = Stroke(width = 1.dp.toPx()) + ) + + // Calculate text position (centered in tooltip) + val textOffset = Offset( + x = tooltipTopLeft.x + (tooltipSize.width - textLayoutResult.size.width) / 2f, + y = tooltipTopLeft.y + (tooltipSize.height - textLayoutResult.size.height) / 2f + ) + + // Draw text + drawContext.canvas.nativeCanvas.apply { + drawText( + textMeasurer = textMeasurer, + text = tooltipText, + style = textStyle, + topLeft = textOffset + ) + } +} + +/** + * Generates the tooltip text content based on the configuration. + * + * @param content Tooltip content configuration + * @param xIndex Index of the point + * @param yValue Y-axis value + * @param xAxisData List of X-axis labels + * @return Formatted tooltip text + */ +private fun generateTooltipText( + content: TooltipContent, + xIndex: Int, + yValue: Double, + xAxisData: List, +): String { + return when (content) { + is TooltipContent.YValue -> { + val formattedValue = content.formatter(yValue) + "${content.label}: $formattedValue" + } + is TooltipContent.XValue -> { + val xValue = content.xAxisData.getOrElse(xIndex) { + xAxisData.getOrElse(xIndex) { xIndex.toString() } + } + "${content.label}: $xValue" + } + is TooltipContent.XYValue -> { + val xValue = content.xAxisData.getOrElse(xIndex) { + xAxisData.getOrElse(xIndex) { xIndex.toString() } + } + val formattedYValue = content.yFormatter(yValue) + "${content.xLabel}: $xValue\n${content.yLabel}: $formattedYValue" + } + is TooltipContent.Custom -> { + content.formatter(xIndex, yValue, xAxisData) + } + } +} \ No newline at end of file diff --git a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/LineParameters.kt b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/LineParameters.kt index e0f37dfa..08e2ece8 100644 --- a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/LineParameters.kt +++ b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/LineParameters.kt @@ -8,5 +8,6 @@ data class LineParameters( val lineColor: Color, val lineType: LineType, val lineShadow: Boolean, + val tooltipConfig: TooltipConfig = TooltipConfig(), ) diff --git a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/TooltipConfig.kt b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/TooltipConfig.kt new file mode 100644 index 00000000..44551303 --- /dev/null +++ b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/TooltipConfig.kt @@ -0,0 +1,120 @@ +package com.aay.compose.lineChart.model + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aay.compose.utils.formatToThousandsMillionsBillions + +/** + * Configuration for customizing the tooltip appearance and content when clicking on line chart points. + * + * @param enabled Whether to show the tooltip when clicking on points + * @param backgroundColor Background color of the tooltip box + * @param borderColor Border color of the tooltip box. If null, uses the line color + * @param textColor Color of the text inside the tooltip + * @param textSize Font size of the tooltip text + * @param cornerRadius Corner radius of the tooltip box + * @param size Size configuration for the tooltip box + * @param content Content configuration defining what to display in the tooltip + * @param padding Internal padding of the tooltip box + * @param markerStyle Style configuration for the circular marker at the clicked point + */ +data class TooltipConfig( + val enabled: Boolean = true, + val backgroundColor: Color = Color.White, + val borderColor: Color? = null, + val textColor: Color = Color.Black, + val textSize: TextUnit = 8.sp, + val cornerRadius: Dp = 16.dp, + val size: TooltipSize = TooltipSize.Auto, + val content: TooltipContent = TooltipContent.YValue(), + val padding: Dp = 8.dp, + val markerStyle: MarkerStyle = MarkerStyle.Stroke(), +) + +/** + * Defines the size of the tooltip box. + */ +sealed class TooltipSize { + /** + * Automatically calculates size based on text content + */ + object Auto : TooltipSize() + + /** + * Fixed size for the tooltip box + * @param width Width of the tooltip + * @param height Height of the tooltip + */ + data class Fixed(val width: Dp, val height: Dp) : TooltipSize() +} + +/** + * Defines the content to display in the tooltip. + */ +sealed class TooltipContent { + /** + * Display only the Y value (data point value) + * @param label Label text to show before the value (e.g., "Value", "القيمة") + * @param formatter Function to format the Y value for display + */ + data class YValue( + val label: String = "Value", + val formatter: (Double) -> String = { it.toFloat().formatToThousandsMillionsBillions() } + ) : TooltipContent() + + /** + * Display only the X value (axis label) + * @param label Label text to show before the X value + * @param xAxisData List of X-axis labels to use for display + */ + data class XValue( + val label: String = "X", + val xAxisData: List = emptyList() + ) : TooltipContent() + + /** + * Display both X and Y values + * @param xLabel Label for the X value + * @param yLabel Label for the Y value + * @param xAxisData List of X-axis labels to use for X value display + * @param yFormatter Function to format the Y value for display + */ + data class XYValue( + val xLabel: String = "X", + val yLabel: String = "Y", + val xAxisData: List = emptyList(), + val yFormatter: (Double) -> String = { it.toFloat().formatToThousandsMillionsBillions() } + ) : TooltipContent() + + /** + * Custom tooltip content with full control over formatting + * @param formatter Function that receives x index, y value, and x-axis data, and returns formatted string + */ + data class Custom( + val formatter: (xIndex: Int, yValue: Double, xAxisData: List) -> String + ) : TooltipContent() +} + +/** + * Defines the style of the circular marker shown at the clicked point. + */ +sealed class MarkerStyle { + /** + * Stroke (outline) circle marker + * @param strokeWidth Width of the circle outline + */ + data class Stroke(val strokeWidth: Dp = 2.dp) : MarkerStyle() + + /** + * Solid (filled) circle marker + */ + object Solid : MarkerStyle() + + /** + * No marker circle (only show tooltip) + */ + object None : MarkerStyle() +} diff --git a/common/src/commonMain/kotlin/com/aay/common/LineChartTooltipExamples.kt b/common/src/commonMain/kotlin/com/aay/common/LineChartTooltipExamples.kt new file mode 100644 index 00000000..35c2ff02 --- /dev/null +++ b/common/src/commonMain/kotlin/com/aay/common/LineChartTooltipExamples.kt @@ -0,0 +1,356 @@ +package com.aay.common + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aay.compose.lineChart.model.LineParameters +import com.aay.compose.lineChart.LineChart +import com.aay.compose.lineChart.model.* + +/** + * Example 1: Default tooltip (backward compatibility) + * Shows the default "Value: X" tooltip with stroke circle marker + */ +@Composable +fun LineChartWithDefaultTooltip() { + val lineParameters = listOf( + LineParameters( + label = "Revenue", + data = listOf(70.0, 80.0, 50.33, 40.0, 100.500, 50.0), + lineColor = Color(0xFF6C3428), + lineType = LineType.CURVED_LINE, + lineShadow = true, + // No tooltipConfig specified - uses default + ) + ) + + Box(Modifier.fillMaxSize()) { + LineChart( + modifier = Modifier.fillMaxSize(), + linesParameters = lineParameters, + isGrid = true, + gridColor = Color.LightGray, + xAxisData = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun"), + animateChart = true, + yAxisRange = 14, + ) + } +} + +/** + * Example 2: Localized tooltip with custom label + * Shows how to localize the tooltip label (e.g., Arabic) + */ +@Composable +fun LineChartWithLocalizedTooltip() { + val lineParameters = listOf( + LineParameters( + label = "الإيرادات", + data = listOf(70.0, 80.0, 50.33, 40.0, 100.500, 50.0), + lineColor = Color(0xFF6C3428), + lineType = LineType.CURVED_LINE, + lineShadow = true, + tooltipConfig = TooltipConfig( + content = TooltipContent.YValue( + label = "القيمة", // "Value" in Arabic + ) + ) + ) + ) + + Box(Modifier.fillMaxSize()) { + LineChart( + modifier = Modifier.fillMaxSize(), + linesParameters = lineParameters, + isGrid = true, + gridColor = Color.LightGray, + xAxisData = listOf("يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو"), + animateChart = true, + yAxisRange = 14, + ) + } +} + +/** + * Example 3: Tooltip showing both X and Y coordinates + * Demonstrates how to display both axis values in the tooltip + */ +@Composable +fun LineChartWithXYTooltip() { + val lineParameters = listOf( + LineParameters( + label = "Sales", + data = listOf(70.0, 80.0, 50.33, 40.0, 100.500, 50.0), + lineColor = Color(0xFF2196F3), + lineType = LineType.DEFAULT_LINE, + lineShadow = true, + tooltipConfig = TooltipConfig( + content = TooltipContent.XYValue( + xLabel = "Month", + yLabel = "Sales", + xAxisData = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun") + ), + backgroundColor = Color.White, + textColor = Color.Black, + textSize = 10.sp, + ) + ) + ) + + Box(Modifier.fillMaxSize()) { + LineChart( + modifier = Modifier.fillMaxSize(), + linesParameters = lineParameters, + isGrid = true, + gridColor = Color.LightGray, + xAxisData = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun"), + animateChart = true, + yAxisRange = 14, + ) + } +} + +/** + * Example 4: Customized tooltip appearance + * Shows how to customize background color, size, corner radius, and text style + */ +@Composable +fun LineChartWithCustomStyledTooltip() { + val lineParameters = listOf( + LineParameters( + label = "Revenue", + data = listOf(70.0, 80.0, 50.33, 40.0, 100.500, 50.0), + lineColor = Color(0xFF4CAF50), + lineType = LineType.CURVED_LINE, + lineShadow = true, + tooltipConfig = TooltipConfig( + backgroundColor = Color(0xFF1E1E1E), // Dark background + borderColor = Color(0xFF4CAF50), // Green border + textColor = Color.White, + textSize = 12.sp, + cornerRadius = 8.dp, + size = TooltipSize.Fixed(width = 120.dp, height = 40.dp), + padding = 12.dp, + content = TooltipContent.YValue( + label = "Revenue", + formatter = { value -> "$${value.toInt()}" } // Custom formatter + ) + ) + ) + ) + + Box(Modifier.fillMaxSize()) { + LineChart( + modifier = Modifier.fillMaxSize(), + linesParameters = lineParameters, + isGrid = true, + gridColor = Color.LightGray, + xAxisData = listOf("Q1", "Q2", "Q3", "Q4", "Q5", "Q6"), + animateChart = true, + yAxisRange = 14, + ) + } +} + +/** + * Example 5: Solid circle marker instead of stroke + * Demonstrates how to use a filled circle marker + */ +@Composable +fun LineChartWithSolidMarker() { + val lineParameters = listOf( + LineParameters( + label = "Temperature", + data = listOf(20.0, 22.5, 25.0, 23.5, 21.0, 19.5), + lineColor = Color(0xFFFF5722), + lineType = LineType.CURVED_LINE, + lineShadow = true, + tooltipConfig = TooltipConfig( + markerStyle = MarkerStyle.Solid, // Solid filled circle + backgroundColor = Color(0xFFFFF3E0), + borderColor = Color(0xFFFF5722), + textColor = Color(0xFF5D4037), + content = TooltipContent.YValue( + label = "Temp", + formatter = { value -> "${value.toInt()}°C" } + ) + ) + ) + ) + + Box(Modifier.fillMaxSize()) { + LineChart( + modifier = Modifier.fillMaxSize(), + linesParameters = lineParameters, + isGrid = true, + gridColor = Color.LightGray, + xAxisData = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat"), + animateChart = true, + yAxisRange = 10, + ) + } +} + +/** + * Example 6: No marker circle (tooltip only) + * Shows tooltip without the circular marker + */ +@Composable +fun LineChartWithTooltipNoMarker() { + val lineParameters = listOf( + LineParameters( + label = "Progress", + data = listOf(10.0, 25.0, 45.0, 60.0, 80.0, 95.0), + lineColor = Color(0xFF9C27B0), + lineType = LineType.DEFAULT_LINE, + lineShadow = true, + tooltipConfig = TooltipConfig( + markerStyle = MarkerStyle.None, // No marker circle + backgroundColor = Color(0xFFF3E5F5), + textColor = Color(0xFF4A148C), + content = TooltipContent.YValue( + label = "Progress", + formatter = { value -> "${value.toInt()}%" } + ) + ) + ) + ) + + Box(Modifier.fillMaxSize()) { + LineChart( + modifier = Modifier.fillMaxSize(), + linesParameters = lineParameters, + isGrid = true, + gridColor = Color.LightGray, + xAxisData = listOf("Week 1", "Week 2", "Week 3", "Week 4", "Week 5", "Week 6"), + animateChart = true, + yAxisRange = 14, + ) + } +} + +/** + * Example 7: Custom tooltip content with formatter + * Demonstrates complete control over tooltip content + */ +@Composable +fun LineChartWithCustomTooltipContent() { + val lineParameters = listOf( + LineParameters( + label = "Users", + data = listOf(1200.0, 1850.0, 2100.0, 1950.0, 2400.0, 2800.0), + lineColor = Color(0xFF00BCD4), + lineType = LineType.CURVED_LINE, + lineShadow = true, + tooltipConfig = TooltipConfig( + markerStyle = MarkerStyle.Stroke(strokeWidth = 3.dp), + backgroundColor = Color(0xFFE0F7FA), + borderColor = Color(0xFF00BCD4), + textColor = Color(0xFF006064), + textSize = 11.sp, + content = TooltipContent.Custom { xIndex, yValue, xAxisData -> + val month = xAxisData.getOrNull(xIndex) ?: "N/A" + val users = yValue.toInt() + "$month\n$users users" + } + ) + ) + ) + + Box(Modifier.fillMaxSize()) { + LineChart( + modifier = Modifier.fillMaxSize(), + linesParameters = lineParameters, + isGrid = true, + gridColor = Color.LightGray, + xAxisData = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun"), + animateChart = true, + yAxisRange = 14, + ) + } +} + +/** + * Example 8: Multi-line chart with different tooltip configs + * Shows how each line can have its own tooltip configuration + */ +@Composable +fun MultiLineChartWithDifferentTooltips() { + val lineParameters = listOf( + LineParameters( + label = "Revenue", + data = listOf(70.0, 80.0, 50.33, 40.0, 100.500, 50.0), + lineColor = Color(0xFF4CAF50), + lineType = LineType.CURVED_LINE, + lineShadow = true, + tooltipConfig = TooltipConfig( + markerStyle = MarkerStyle.Solid, + backgroundColor = Color(0xFFE8F5E9), + textColor = Color(0xFF1B5E20), + content = TooltipContent.YValue(label = "Revenue") + ) + ), + LineParameters( + label = "Expenses", + data = listOf(60.0, 70.6, 40.33, 86.232, 88.0, 90.0), + lineColor = Color(0xFFFF5722), + lineType = LineType.DEFAULT_LINE, + lineShadow = true, + tooltipConfig = TooltipConfig( + markerStyle = MarkerStyle.Stroke(strokeWidth = 2.dp), + backgroundColor = Color(0xFFFBE9E7), + textColor = Color(0xFFBF360C), + content = TooltipContent.YValue(label = "Expenses") + ) + ), + ) + + Box(Modifier.fillMaxSize()) { + LineChart( + modifier = Modifier.fillMaxSize(), + linesParameters = lineParameters, + isGrid = true, + gridColor = Color.LightGray, + xAxisData = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun"), + animateChart = true, + yAxisRange = 14, + oneLineChart = false, + ) + } +} + +/** + * Example 9: Disabled tooltip + * Shows how to disable the tooltip completely + */ +@Composable +fun LineChartWithDisabledTooltip() { + val lineParameters = listOf( + LineParameters( + label = "Data", + data = listOf(70.0, 80.0, 50.33, 40.0, 100.500, 50.0), + lineColor = Color(0xFF607D8B), + lineType = LineType.CURVED_LINE, + lineShadow = true, + tooltipConfig = TooltipConfig( + enabled = false // Disable tooltip + ) + ) + ) + + Box(Modifier.fillMaxSize()) { + LineChart( + modifier = Modifier.fillMaxSize(), + linesParameters = lineParameters, + isGrid = true, + gridColor = Color.LightGray, + xAxisData = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun"), + animateChart = true, + yAxisRange = 14, + ) + } +} From bcad6d6fbe20b954a9e15206d055060f5c5b4fd8 Mon Sep 17 00:00:00 2001 From: abdo-essam Date: Thu, 22 Jan 2026 18:24:23 +0200 Subject: [PATCH 2/2] feat: Enhance tooltip customization This commit introduces advanced customization options for line chart tooltips: - **Individual Corner Radii**: Allows setting unique corner radii for each corner of the tooltip box via the new `TooltipCornerRadii` data class. - **Tooltip Positioning**: Adds a `TooltipPosition` sealed class (Center, Left, Right) to control the horizontal alignment of the tooltip relative to the data point. The implementation handles drawing tooltips with either uniform or individual corner radii and updates the tooltip's position based on the new alignment setting. --- .../lineChart/components/TooltipWithMarker.kt | 143 +++++++++++++----- .../compose/lineChart/model/TooltipConfig.kt | 41 ++++- .../aay/common/LineChartTooltipExamples.kt | 12 +- 3 files changed, 158 insertions(+), 38 deletions(-) diff --git a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/TooltipWithMarker.kt b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/TooltipWithMarker.kt index 71064b79..64a3f8ee 100644 --- a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/TooltipWithMarker.kt +++ b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/components/TooltipWithMarker.kt @@ -5,8 +5,10 @@ import androidx.compose.animation.core.AnimationVector1D import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke @@ -20,12 +22,15 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.aay.compose.lineChart.model.LineParameters import com.aay.compose.lineChart.model.MarkerStyle +import com.aay.compose.lineChart.model.TooltipConfig import com.aay.compose.lineChart.model.TooltipContent +import com.aay.compose.lineChart.model.TooltipCornerRadii +import com.aay.compose.lineChart.model.TooltipPosition import com.aay.compose.lineChart.model.TooltipSize /** * Draws a tooltip with a circular marker at the clicked point on the line chart. - * + * * This is the main entry point for rendering tooltips when a user clicks on a data point. * It draws both the marker circle and the tooltip box with customizable content and styling. * @@ -50,9 +55,9 @@ internal fun DrawScope.drawTooltipWithMarker( xAxisData: List = emptyList(), ) { val config = line.tooltipConfig - + if (!config.enabled) return - + // Draw marker circle at the point drawMarkerCircle( x = x.toPx(), @@ -61,7 +66,7 @@ internal fun DrawScope.drawTooltipWithMarker( animatedProgress = animatedProgress, markerStyle = config.markerStyle ) - + // Draw tooltip box with content drawTooltipBox( x = x, @@ -101,6 +106,7 @@ private fun DrawScope.drawMarkerCircle( stroke = Stroke(width = markerStyle.strokeWidth.toPx()) ) } + is MarkerStyle.Solid -> { chartCircle( x = x, @@ -110,6 +116,7 @@ private fun DrawScope.drawMarkerCircle( stroke = null // null means filled circle ) } + is MarkerStyle.None -> { // Don't draw any marker } @@ -137,7 +144,7 @@ private fun DrawScope.drawTooltipBox( xIndex: Int, yValue: Double, xAxisData: List, - config: com.aay.compose.lineChart.model.TooltipConfig, + config: TooltipConfig, ) { // Generate tooltip text based on content configuration val tooltipText = generateTooltipText( @@ -146,19 +153,19 @@ private fun DrawScope.drawTooltipBox( yValue = yValue, xAxisData = xAxisData ) - + // Create text style val textStyle = TextStyle( fontSize = config.textSize, color = config.textColor ) - + // Measure text to determine tooltip size val textLayoutResult = textMeasurer.measure( text = AnnotatedString(tooltipText), style = textStyle ) - + // Calculate tooltip box size val tooltipSize = when (config.size) { is TooltipSize.Auto -> { @@ -168,6 +175,7 @@ private fun DrawScope.drawTooltipBox( height = textLayoutResult.size.height + (config.padding.toPx() * 2) ) } + is TooltipSize.Fixed -> { Size( width = config.size.width.toPx(), @@ -175,40 +183,57 @@ private fun DrawScope.drawTooltipBox( ) } } - - // Calculate tooltip position (centered above the point) + + // Calculate tooltip position based on alignment + val tooltipX = when (config.position) { + is TooltipPosition.Center -> x.toPx() - tooltipSize.width / 2f + is TooltipPosition.Left -> x.toPx() - tooltipSize.width + is TooltipPosition.Right -> x.toPx() + } + val tooltipTopLeft = Offset( - x = x.toPx() - tooltipSize.width / 2f, - y = y.toFloat() - tooltipSize.height - 20.dp.toPx() // 20dp gap above the point - ) - - val tooltipBounds = Rect(tooltipTopLeft, tooltipSize) - - // Draw tooltip background - drawRoundRect( - color = config.backgroundColor, - topLeft = tooltipBounds.topLeft, - size = tooltipBounds.size, - cornerRadius = CornerRadius(config.cornerRadius.toPx()), - style = Fill + x = tooltipX, + y = y.toFloat() - tooltipSize.height - 10.dp.toPx() ) - - // Draw tooltip border + val borderColor = config.borderColor ?: color - drawRoundRect( - color = borderColor, - topLeft = tooltipBounds.topLeft, - size = tooltipBounds.size, - cornerRadius = CornerRadius(config.cornerRadius.toPx()), - style = Stroke(width = 1.dp.toPx()) - ) - + + // Draw tooltip with appropriate corner handling + val radii = config.cornerRadii + if (radii != null) { + // Use individual corner radii with Path + drawTooltipWithIndividualCorners( + topLeft = tooltipTopLeft, + size = tooltipSize, + radii = radii, + backgroundColor = config.backgroundColor, + borderColor = borderColor + ) + } else { + // Use uniform corner radius + val tooltipBounds = Rect(tooltipTopLeft, tooltipSize) + drawRoundRect( + color = config.backgroundColor, + topLeft = tooltipBounds.topLeft, + size = tooltipBounds.size, + cornerRadius = CornerRadius(config.cornerRadius.toPx()), + style = Fill + ) + drawRoundRect( + color = borderColor, + topLeft = tooltipBounds.topLeft, + size = tooltipBounds.size, + cornerRadius = CornerRadius(config.cornerRadius.toPx()), + style = Stroke(width = 1.dp.toPx()) + ) + } + // Calculate text position (centered in tooltip) val textOffset = Offset( x = tooltipTopLeft.x + (tooltipSize.width - textLayoutResult.size.width) / 2f, y = tooltipTopLeft.y + (tooltipSize.height - textLayoutResult.size.height) / 2f ) - + // Draw text drawContext.canvas.nativeCanvas.apply { drawText( @@ -240,12 +265,14 @@ private fun generateTooltipText( val formattedValue = content.formatter(yValue) "${content.label}: $formattedValue" } + is TooltipContent.XValue -> { - val xValue = content.xAxisData.getOrElse(xIndex) { + val xValue = content.xAxisData.getOrElse(xIndex) { xAxisData.getOrElse(xIndex) { xIndex.toString() } } "${content.label}: $xValue" } + is TooltipContent.XYValue -> { val xValue = content.xAxisData.getOrElse(xIndex) { xAxisData.getOrElse(xIndex) { xIndex.toString() } @@ -253,8 +280,54 @@ private fun generateTooltipText( val formattedYValue = content.yFormatter(yValue) "${content.xLabel}: $xValue\n${content.yLabel}: $formattedYValue" } + is TooltipContent.Custom -> { content.formatter(xIndex, yValue, xAxisData) } } +} + +/** + * Draws a rounded rectangle with individual corner radii using Path. + * + * @param topLeft Top-left position of the rectangle + * @param size Size of the rectangle + * @param radii Individual corner radii + * @param backgroundColor Fill color + * @param borderColor Border color + */ +private fun DrawScope.drawTooltipWithIndividualCorners( + topLeft: Offset, + size: Size, + radii: TooltipCornerRadii, + backgroundColor: Color, + borderColor: Color, +) { + val rect = Rect(topLeft, size) + + val roundRect = RoundRect( + rect = rect, + topLeft = CornerRadius(radii.topLeft.toPx()), + topRight = CornerRadius(radii.topRight.toPx()), + bottomLeft = CornerRadius(radii.bottomLeft.toPx()), + bottomRight = CornerRadius(radii.bottomRight.toPx()) + ) + + val path = Path().apply { + addRoundRect(roundRect) + } + + // Draw fill + drawPath( + path = path, + color = backgroundColor, + style = Fill + ) + + // Draw border + drawPath( + path = path, + color = borderColor, + style = Stroke(width = 1.dp.toPx()) + ) } \ No newline at end of file diff --git a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/TooltipConfig.kt b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/TooltipConfig.kt index 44551303..223723e7 100644 --- a/chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/TooltipConfig.kt +++ b/chart/src/commonMain/kotlin/com/aay/compose/lineChart/model/TooltipConfig.kt @@ -15,11 +15,13 @@ import com.aay.compose.utils.formatToThousandsMillionsBillions * @param borderColor Border color of the tooltip box. If null, uses the line color * @param textColor Color of the text inside the tooltip * @param textSize Font size of the tooltip text - * @param cornerRadius Corner radius of the tooltip box + * @param cornerRadius Corner radius for all corners (used when cornerRadii is null) + * @param cornerRadii Individual corner radii. If provided, overrides cornerRadius * @param size Size configuration for the tooltip box * @param content Content configuration defining what to display in the tooltip * @param padding Internal padding of the tooltip box * @param markerStyle Style configuration for the circular marker at the clicked point + * @param position Horizontal position alignment of the tooltip relative to the point */ data class TooltipConfig( val enabled: Boolean = true, @@ -28,12 +30,49 @@ data class TooltipConfig( val textColor: Color = Color.Black, val textSize: TextUnit = 8.sp, val cornerRadius: Dp = 16.dp, + val cornerRadii: TooltipCornerRadii? = null, val size: TooltipSize = TooltipSize.Auto, val content: TooltipContent = TooltipContent.YValue(), val padding: Dp = 8.dp, val markerStyle: MarkerStyle = MarkerStyle.Stroke(), + val position: TooltipPosition = TooltipPosition.Center, ) +/** + * Individual corner radii for the tooltip box. + * + * @param topLeft Top-left corner radius + * @param topRight Top-right corner radius + * @param bottomLeft Bottom-left corner radius + * @param bottomRight Bottom-right corner radius + */ +data class TooltipCornerRadii( + val topLeft: Dp = 16.dp, + val topRight: Dp = 16.dp, + val bottomLeft: Dp = 16.dp, + val bottomRight: Dp = 16.dp, +) + +/** + * Defines the horizontal position of the tooltip relative to the clicked point. + */ +sealed class TooltipPosition { + /** + * Tooltip is centered horizontally above the point + */ + object Center : TooltipPosition() + + /** + * Tooltip is aligned to the left of the point + */ + object Left : TooltipPosition() + + /** + * Tooltip is aligned to the right of the point + */ + object Right : TooltipPosition() +} + /** * Defines the size of the tooltip box. */ diff --git a/common/src/commonMain/kotlin/com/aay/common/LineChartTooltipExamples.kt b/common/src/commonMain/kotlin/com/aay/common/LineChartTooltipExamples.kt index 35c2ff02..54b44e6e 100644 --- a/common/src/commonMain/kotlin/com/aay/common/LineChartTooltipExamples.kt +++ b/common/src/commonMain/kotlin/com/aay/common/LineChartTooltipExamples.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -171,12 +172,19 @@ fun LineChartWithSolidMarker() { lineShadow = true, tooltipConfig = TooltipConfig( markerStyle = MarkerStyle.Solid, // Solid filled circle - backgroundColor = Color(0xFFFFF3E0), - borderColor = Color(0xFFFF5722), + backgroundColor = Color(0xFFF8F8F8), + //borderColor = Color(0xFFFF5722), textColor = Color(0xFF5D4037), content = TooltipContent.YValue( label = "Temp", formatter = { value -> "${value.toInt()}°C" } + ), + position = TooltipPosition.Left, + cornerRadii = TooltipCornerRadii( + topLeft = 8.dp, + topRight = 8.dp, + bottomLeft = 8.dp, + bottomRight = 2.dp ) ) )