Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created June 4, 2026 13:18
Show Gist options
  • Select an option

  • Save Kyriakos-Georgiopoulos/eb7118d14e1c5fa796ac85822cc2d0e7 to your computer and use it in GitHub Desktop.

Select an option

Save Kyriakos-Georgiopoulos/eb7118d14e1c5fa796ac85822cc2d0e7 to your computer and use it in GitHub Desktop.
/*
* Copyright 2026 Kyriakos Georgiopoulos
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
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.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
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.PathEffect
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.sqrt
// ==========================================
// 1. DATA MODELS & CONFIG
// ==========================================
/**
* A single data point on a line chart.
*
* Points within a [LineSeries] must be sorted by [x] in ascending order.
* The chart does not sort internally.
*
* @param x Horizontal position. Can represent time, index, or any continuous scale.
* @param y Vertical value. Negative values are supported when
* [LineAxisConfig.includeZeroInYRange] is false.
* @param label Optional x-axis label at this point (e.g. "Jan", "Q1").
* When empty, [LineAxisConfig.xLabelFormatter] is called with [x] instead.
*/
@Immutable
data class LineDataPoint(
val x: Float,
val y: Float,
val label: String = ""
)
/**
* A data series rendered as a single line on the chart.
*
* Each series produces a stroke path and optionally an area fill beneath it.
* Multiple series overlay in list order (first series draws behind).
*
* ```
* LineSeries(
* id = "revenue",
* label = "Revenue",
* points = months.mapIndexed { i, name -> LineDataPoint(i.toFloat(), values[i], name) },
* color = Color(0xFF6366F1),
* fillAlpha = 0.12f,
* strokeGradientColors = listOf(Color(0xFF818CF8), Color(0xFF4F46E5))
* )
* ```
*
* @param id Stable identifier for animation tracking. Changing the id is treated
* as a removal + insertion, not a morph. Must be unique within the dataset.
* @param label Human-readable name shown in crosshair tooltips and accessibility.
* @param points Data sorted by [LineDataPoint.x]. All series in a dataset should
* ideally share the same x positions for correct crosshair alignment.
* @param color Primary color used for the line stroke, data dots, and the
* auto-generated area fill gradient. Ignored for stroke when
* [strokeGradientColors] has 2+ entries.
* @param fillAlpha Opacity of the area fill under the curve. 0 = no fill (line only),
* 0.1..0.2 = subtle wash, 1.0 = fully opaque. The fill is a vertical gradient
* from [color] at the curve to transparent at the x-axis.
* @param strokeWidth Thickness of the line stroke.
* @param fillGradientColors Overrides the auto-generated area fill gradient with
* explicit colors. Applied as a top-to-bottom vertical gradient.
* @param strokeGradientColors When set with 2+ colors, the line stroke renders
* as a horizontal gradient mapped from the leftmost to the rightmost data point.
* Falls back to solid [color] when empty.
*/
@Immutable
data class LineSeries(
val id: String,
val label: String,
val points: List<LineDataPoint>,
val color: Color = Color(0xFF6366F1),
val fillAlpha: Float = 0f,
val strokeWidth: Dp = 2.5.dp,
val fillGradientColors: List<Color> = emptyList(),
val strokeGradientColors: List<Color> = emptyList()
)
/**
* Groups one or more [LineSeries] into a renderable dataset.
*
* @param series Lines to render. Drawing order: index 0 draws behind index N.
* @param contentDescription Root accessibility label for the chart container.
*/
@Immutable
data class LineDataSet(
val series: List<LineSeries>,
val contentDescription: String = "Line Chart"
)
/**
* Curve interpolation between data points.
*
* [Linear] connects points with straight segments. Fast to compute, clear for
* sparse or step-like data.
*
* [MonotoneCubic] uses the Fritsch-Carlson algorithm to produce smooth C1-continuous
* cubic bezier curves that are mathematically guaranteed not to overshoot data points.
* This is the standard for financial charts, analytics dashboards, and any dataset
* where visual smoothness matters without introducing phantom peaks.
*/
enum class LineCurveType {
Linear,
MonotoneCubic
}
/**
* Controls axes, grid, and labels.
*
* The y-axis range is auto-computed from the data with nice-number rounding
* (1, 2, 5 multiples) so tick labels are always clean values like 0, 20, 40, 60
* rather than 0, 17.4, 34.8.
*
* @param showGrid Horizontal grid lines at each y-tick value.
* @param showVerticalGrid Vertical grid lines at each x data point.
* @param gridColor Grid line color. Use a very light tone to avoid overwhelming data.
* @param gridStrokeWidth Grid line width. 1.dp is standard.
* @param axisColor Color for the x-axis and y-axis baseline.
* @param axisStrokeWidth Width of axis lines.
* @param labelColor Text color for both x and y labels.
* @param labelFontSize Font size for axis labels.
* @param yTickCount Desired number of y-axis intervals. The actual count may differ
* slightly due to nice-number rounding (e.g. 5 requested, 6 produced if the range
* rounds better that way).
* @param xLabelFormatter Converts x-values to label strings. Called when
* [LineDataPoint.label] is empty. Default produces integers.
* @param yLabelFormatter Converts y-tick values to label strings. Default produces
* integers. Override for currency, percentages, or decimal formatting.
* @param includeZeroInYRange When true, the y-axis always starts at or below 0.
* Set to false for datasets where all values are large (e.g. 500..600) to zoom in.
* @param showXLabels Show labels below the x-axis.
* @param showYLabels Show labels beside the y-axis.
* @param maxXLabels Upper bound on visible x-labels. When more data points exist,
* labels are thinned by showing every Nth label. Prevents crowding.
* @param dashedGrid Render grid lines as dashed instead of solid.
*/
@Immutable
data class LineAxisConfig(
val showGrid: Boolean = true,
val showVerticalGrid: Boolean = false,
val gridColor: Color = Color(0xFFF1F5F9),
val gridStrokeWidth: Dp = 1.dp,
val axisColor: Color = Color(0xFFE2E8F0),
val axisStrokeWidth: Dp = 1.dp,
val labelColor: Color = Color(0xFF94A3B8),
val labelFontSize: TextUnit = 10.sp,
val yTickCount: Int = 5,
val xLabelFormatter: (Float) -> String = { it.toInt().toString() },
val yLabelFormatter: (Float) -> String = { it.toInt().toString() },
val includeZeroInYRange: Boolean = true,
val showXLabels: Boolean = true,
val showYLabels: Boolean = true,
val maxXLabels: Int = 12,
val dashedGrid: Boolean = false
)
/**
* Controls the crosshair shown when the user touches or drags on the chart.
*
* The crosshair snaps to the nearest x data point and shows a vertical line,
* highlighted dots on each series, and an optional tooltip. During a drag,
* the crosshair follows the finger in real time.
*
* @param enabled Master toggle. When false, no pointer input is registered.
* @param showTooltip Show a floating label with series values at the selected point.
* @param lineColor Color of the vertical crosshair line.
* @param lineWidth Width of the crosshair line.
* @param dotRadius Size of the highlighted dot on each series at the selected x.
* @param dotBorderWidth White border around each highlighted dot for contrast.
* @param dotBorderColor Border color (typically white or the chart background).
* @param tooltipBackground Background color of the tooltip panel.
* @param tooltipTextColor Text color inside the tooltip.
* @param tooltipFontSize Font size for tooltip text.
* @param tooltipCornerRadius Corner rounding of the tooltip panel.
* @param tooltipPadding Internal padding of the tooltip panel.
* @param tooltipFormatter Builds the tooltip text for each series at the selected
* point. Called once per series. Lines are joined with newlines.
*/
@Immutable
data class LineCrosshairConfig(
val enabled: Boolean = true,
val showTooltip: Boolean = true,
val lineColor: Color = Color(0xFFCBD5E1),
val lineWidth: Dp = 1.dp,
val dotRadius: Dp = 6.dp,
val dotBorderWidth: Dp = 2.5.dp,
val dotBorderColor: Color = Color.White,
val tooltipBackground: Color = Color(0xFF111827),
val tooltipTextColor: Color = Color.White,
val tooltipFontSize: TextUnit = 11.sp,
val tooltipCornerRadius: Dp = 8.dp,
val tooltipPadding: Dp = 8.dp,
val tooltipFormatter: (LineSeries, LineDataPoint) -> String = { s, p ->
"${s.label}: ${p.y.toInt()}"
}
)
/**
* Visual configuration for the chart.
*
* @param curveType Interpolation strategy. [LineCurveType.MonotoneCubic] is recommended
* for smooth, accurate curves.
* @param showDots Render small dots at every data point (independent of crosshair).
* @param dotRadius Radius of the always-visible data point dots.
* @param minSize Minimum intrinsic chart size. Applied via [Modifier.defaultMinSize].
* @param labelGap Gap in dp between axis labels and the chart drawing area.
*/
@Immutable
data class LineChartStyle(
val curveType: LineCurveType = LineCurveType.MonotoneCubic,
val showDots: Boolean = false,
val dotRadius: Dp = 3.dp,
val minSize: Dp = 200.dp,
val labelGap: Dp = 8.dp
)
/**
* Animation timing for line entry, data morph, and series stagger.
*
* On first appearance, each point's y-value animates from the baseline (y-axis
* minimum) to its target. Points animate left-to-right with a cascading delay,
* creating a "wave" reveal effect. When data changes, existing points spring
* from their current position to the new target simultaneously.
*
* @param entrySpec Drives the initial point reveal. A tween with ease-out works well.
* @param morphSpec Drives value changes on existing points. A spring gives elastic feel.
* @param staggerMs Delay between successive points within a single series.
* 12ms for 50 points = 600ms spread. Increase for fewer points to make the
* wave visible.
* @param startDelayMs Delay before the very first point begins animating.
* @param seriesStaggerMs Delay between series. The second series starts
* [seriesStaggerMs] after the first, creating a layered reveal.
*/
@Immutable
data class LineAnimationConfig(
val entrySpec: AnimationSpec<Float> = tween(900, easing = FastOutSlowInEasing),
val morphSpec: AnimationSpec<Float> = spring(
dampingRatio = 0.8f,
stiffness = Spring.StiffnessLow
),
val staggerMs: Long = 12L,
val startDelayMs: Long = 80L,
val seriesStaggerMs: Long = 120L
)
/**
* Accessibility description builders.
*
* @param chartDescriptionBuilder Builds the base screen reader description from the
* full dataset. Called once per data change. Default announces series names,
* value ranges, and point counts.
* @param selectedPointDescriptionBuilder Appended to the base description when
* the crosshair is active. Announces the value at the selected point for each
* series. TalkBack reads this on each crosshair move.
*/
@Stable
data class LineA11yConfig(
val chartDescriptionBuilder: (LineDataSet) -> String = { ds ->
buildString {
append("Line Chart: ${ds.contentDescription}. ")
ds.series.forEach { s ->
val mn = s.points.minOfOrNull { it.y }?.toInt() ?: 0
val mx = s.points.maxOfOrNull { it.y }?.toInt() ?: 0
append("${s.label}: range $mn to $mx, ${s.points.size} points. ")
}
}
},
val selectedPointDescriptionBuilder: (Int, List<LineSeries>) -> String = { idx, series ->
buildString {
series.forEach { s ->
if (idx in s.points.indices) {
val p = s.points[idx]
val lbl = p.label.ifEmpty { p.x.toInt().toString() }
append("${s.label} at $lbl: ${p.y.toInt()}. ")
}
}
}
}
)
// ==========================================
// 2. INTERNAL HELPERS
// ==========================================
/**
* Rounds axis step to a "nice" number (1, 2, 5 * 10^n) and generates evenly
* spaced tick values that fully contain the data range.
*/
private fun computeNiceAxisTicks(dataMin: Float, dataMax: Float, tickCount: Int): List<Float> {
if (tickCount <= 0 || dataMax <= dataMin) return listOf(dataMin, dataMax)
val rawStep = (dataMax - dataMin) / tickCount
if (rawStep <= 0f) return listOf(dataMin, dataMax)
val magnitude = 10f.pow(floor(log10(rawStep)))
val norm = rawStep / magnitude
val niceStep = when {
norm <= 1.0f -> magnitude
norm <= 2.0f -> 2f * magnitude
norm <= 5.0f -> 5f * magnitude
else -> 10f * magnitude
}
val niceMin = floor(dataMin / niceStep) * niceStep
val niceMax = ceil(dataMax / niceStep) * niceStep
val count = ((niceMax - niceMin) / niceStep + 0.5f).toInt()
return (0..count).map { niceMin + it * niceStep }
}
/**
* Fritsch-Carlson monotone cubic tangent computation. Operates entirely on
* pre-allocated [FloatArray] buffers with zero heap allocation.
*
* [deltas] is a scratch buffer of size >= n-1 that avoids a per-frame allocation.
* The result is written into [tangents] in-place.
*/
private fun computeMonotoneTangents(
xs: FloatArray, ys: FloatArray, tangents: FloatArray, deltas: FloatArray, n: Int
) {
if (n < 2) return
for (i in 0 until n - 1) {
val dx = xs[i + 1] - xs[i]
deltas[i] = if (dx == 0f) 0f else (ys[i + 1] - ys[i]) / dx
}
tangents[0] = deltas[0]
tangents[n - 1] = deltas[n - 2]
for (i in 1 until n - 1) {
tangents[i] = if (deltas[i - 1] * deltas[i] <= 0f) 0f
else (deltas[i - 1] + deltas[i]) / 2f
}
// Monotonicity enforcement: clamp tangent magnitudes so alpha^2 + beta^2 <= 9
for (i in 0 until n - 1) {
if (deltas[i] == 0f) {
tangents[i] = 0f; tangents[i + 1] = 0f
} else {
val a = tangents[i] / deltas[i]
val b = tangents[i + 1] / deltas[i]
val s = a * a + b * b
if (s > 9f) {
val tau = 3f / sqrt(s)
tangents[i] = tau * a * deltas[i]
tangents[i + 1] = tau * b * deltas[i]
}
}
}
}
/** Builds a line path (stroke only) from pre-computed screen-space buffers. */
private fun Path.buildCurve(
xs: FloatArray, ys: FloatArray, tangents: FloatArray, n: Int,
curveType: LineCurveType
) {
if (n < 1) return
moveTo(xs[0], ys[0])
if (n == 1) return
when (curveType) {
LineCurveType.Linear -> for (i in 1 until n) lineTo(xs[i], ys[i])
LineCurveType.MonotoneCubic -> for (i in 0 until n - 1) {
val dx = (xs[i + 1] - xs[i]) / 3f
cubicTo(
xs[i] + dx, ys[i] + tangents[i] * dx,
xs[i + 1] - dx, ys[i + 1] - tangents[i + 1] * dx,
xs[i + 1], ys[i + 1]
)
}
}
}
/** Builds a closed area path: curve on top, straight bottom edge at [chartBottom]. */
private fun Path.buildArea(
xs: FloatArray, ys: FloatArray, tangents: FloatArray, n: Int,
chartBottom: Float, curveType: LineCurveType
) {
if (n < 1) return
buildCurve(xs, ys, tangents, n, curveType)
lineTo(xs[n - 1], chartBottom)
lineTo(xs[0], chartBottom)
close()
}
// ==========================================
// 3. ANIMATION ENGINE
// ==========================================
/**
* Manages per-point Y-value [Animatable] instances keyed as `"seriesId::pointIndex"`.
*
* Lifecycle (same pattern as PieChart/RadarChart):
* - [syncAnimatables]: synchronous map housekeeping via [SideEffect], ensures
* animatable instances exist for current data before the first draw.
* - [launchEntryAnimations]: staggered async animations via [LaunchedEffect] scope.
* Old coroutines cancel automatically when data changes.
*/
@Stable
class LineChartAnimationEngine {
internal val yAnimatables = mutableMapOf<String, Animatable<Float, AnimationVector1D>>()
private val initializedKeys = mutableSetOf<String>()
/** Ensures animatables exist for all current points. Removes stale entries. */
fun syncAnimatables(series: List<LineSeries>) {
val activeKeys = mutableSetOf<String>()
series.forEach { s ->
s.points.forEachIndexed { i, _ ->
val key = "${s.id}::$i"
activeKeys.add(key)
yAnimatables.getOrPut(key) { Animatable(0f) }
}
}
yAnimatables.keys.removeAll { it !in activeKeys }
initializedKeys.removeAll { it !in activeKeys }
}
/**
* Launches staggered entry or morph animations. New points animate from
* [yBaseline]; existing points spring to their updated target.
*/
fun launchEntryAnimations(
series: List<LineSeries>,
config: LineAnimationConfig,
yBaseline: Float,
scope: CoroutineScope
) {
series.forEachIndexed { si, s ->
s.points.forEachIndexed { pi, point ->
val key = "${s.id}::$pi"
val anim = yAnimatables[key] ?: return@forEachIndexed
val isInitial = initializedKeys.add(key)
scope.launch {
if (isInitial) {
anim.snapTo(yBaseline)
delay(config.startDelayMs + si * config.seriesStaggerMs + pi * config.staggerMs)
anim.animateTo(point.y, config.entrySpec)
} else if (anim.targetValue != point.y) {
anim.animateTo(point.y, config.morphSpec)
}
}
}
}
}
}
// ==========================================
// 4. THE CHART COMPOSABLE
// ==========================================
/**
* A composable line chart with monotone cubic bezier curves, gradient strokes,
* area fills, drag crosshair with tooltips, animated entry/morph, full RTL
* mirroring, and accessibility support.
*
* Basic usage:
* ```
* val data = LineDataSet(
* series = listOf(
* LineSeries("sales", "Sales",
* points = values.mapIndexed { i, v -> LineDataPoint(i.toFloat(), v, months[i]) },
* color = Color(0xFF6366F1),
* fillAlpha = 0.12f
* )
* )
* )
*
* var selected by remember { mutableStateOf<Int?>(null) }
*
* LineChart(
* dataSet = data,
* modifier = Modifier.fillMaxWidth().height(300.dp),
* selectedPointIndex = selected,
* onPointSelected = { selected = it }
* )
* ```
*
* Gradient stroke + area fill:
* ```
* LineSeries("rev", "Revenue",
* points = ...,
* strokeGradientColors = listOf(Color(0xFF818CF8), Color(0xFF4F46E5)),
* fillAlpha = 0.15f,
* fillGradientColors = listOf(Color(0xFF818CF8).copy(alpha = 0.3f), Color.Transparent)
* )
* ```
*
* @param dataSet Series and chart description to render.
* @param style Visual configuration: curve type, dots, sizing.
* @param axisConfig Axes, grid lines, labels, and value formatting.
* @param crosshairConfig Crosshair interaction and tooltip appearance.
* @param animationConfig Timing for entry wave, morph spring, and stagger delays.
* @param a11yConfig Accessibility description builders for TalkBack.
* @param selectedPointIndex Currently highlighted x-axis data point index, or null.
* Hoist this in the parent to control crosshair externally.
* @param onPointSelected Called during touch/drag (with the nearest point index)
* and on finger release (with null). The crosshair renders at this index.
*/
@Composable
fun LineChart(
dataSet: LineDataSet,
modifier: Modifier = Modifier,
style: LineChartStyle = LineChartStyle(),
axisConfig: LineAxisConfig = LineAxisConfig(),
crosshairConfig: LineCrosshairConfig = LineCrosshairConfig(),
animationConfig: LineAnimationConfig = LineAnimationConfig(),
a11yConfig: LineA11yConfig = LineA11yConfig(),
selectedPointIndex: Int? = null,
onPointSelected: (Int?) -> Unit = {}
) {
val textMeasurer = rememberTextMeasurer()
val series = dataSet.series
val density = LocalDensity.current
val layoutDirection = LocalLayoutDirection.current
val isRtl = layoutDirection == LayoutDirection.Rtl
val animationEngine = remember { LineChartAnimationEngine() }
// ── Stable state refs for pointerInput(Unit) ──
val currentSeries by rememberUpdatedState(series)
val currentOnPointSelected by rememberUpdatedState(onPointSelected)
val currentDensity by rememberUpdatedState(density)
val currentIsRtl by rememberUpdatedState(isRtl)
// ── Data ranges (recomputed only on data change) ──
val allPoints = remember(series) { series.flatMap { it.points } }
val xMin = remember(allPoints) { allPoints.minOfOrNull { it.x } ?: 0f }
val xMax = remember(allPoints) { allPoints.maxOfOrNull { it.x } ?: 1f }
val currentXMin by rememberUpdatedState(xMin)
val currentXMax by rememberUpdatedState(xMax)
val yDataMax = remember(allPoints) { allPoints.maxOfOrNull { it.y } ?: 1f }
val yDataMin = remember(allPoints, axisConfig.includeZeroInYRange) {
val raw = allPoints.minOfOrNull { it.y } ?: 0f
if (axisConfig.includeZeroInYRange) min(0f, raw) else raw
}
val yTickValues = remember(yDataMin, yDataMax, axisConfig.yTickCount) {
computeNiceAxisTicks(yDataMin, yDataMax, axisConfig.yTickCount)
}
val yMin = yTickValues.firstOrNull() ?: 0f
val yMax = yTickValues.lastOrNull() ?: 1f
// ── Pre-measured labels (zero text measurement in draw) ──
val labelStyle = remember(axisConfig.labelColor, axisConfig.labelFontSize) {
TextStyle(
color = axisConfig.labelColor,
fontSize = axisConfig.labelFontSize,
fontWeight = FontWeight.Medium
)
}
val yLabelLayouts = remember(yTickValues, labelStyle, axisConfig.yLabelFormatter) {
yTickValues.map { v ->
textMeasurer.measure(axisConfig.yLabelFormatter(v), style = labelStyle, maxLines = 1)
}
}
val maxYLabelWidth = remember(yLabelLayouts) {
if (yLabelLayouts.isEmpty()) 0f else yLabelLayouts.maxOf { it.size.width }.toFloat()
}
val currentMaxYLabelWidth by rememberUpdatedState(maxYLabelWidth)
val firstPoints = series.firstOrNull()?.points ?: emptyList()
val xLabelInterval = remember(firstPoints, axisConfig.maxXLabels) {
max(1, (firstPoints.size + axisConfig.maxXLabels - 1) / axisConfig.maxXLabels)
}
val xLabelLayouts = remember(
firstPoints,
labelStyle,
xLabelInterval,
axisConfig.xLabelFormatter,
axisConfig.showXLabels
) {
if (!axisConfig.showXLabels) emptyList()
else firstPoints.filterIndexed { i, _ -> i % xLabelInterval == 0 }.map { p ->
textMeasurer.measure(
p.label.ifEmpty { axisConfig.xLabelFormatter(p.x) },
style = labelStyle.copy(textAlign = TextAlign.Center),
maxLines = 1
)
}
}
val maxXLabelHeight = remember(xLabelLayouts) {
if (xLabelLayouts.isEmpty()) 0f else xLabelLayouts.maxOf { it.size.height }.toFloat()
}
val tooltipStyle = remember(crosshairConfig.tooltipTextColor, crosshairConfig.tooltipFontSize) {
TextStyle(
color = crosshairConfig.tooltipTextColor,
fontSize = crosshairConfig.tooltipFontSize,
fontWeight = FontWeight.Medium,
lineHeight = crosshairConfig.tooltipFontSize * 1.4f
)
}
// ── Pre-computed key matrix + draw buffers (zero allocation in draw) ──
val seriesStructure = remember(series) { series.map { it.id to it.points.size } }
val keyMatrix =
remember(seriesStructure) { series.associate { s -> s.id to Array(s.points.size) { i -> "${s.id}::$i" } } }
val xBuffers =
remember(seriesStructure) { series.associate { s -> s.id to FloatArray(s.points.size) } }
val yBuffers =
remember(seriesStructure) { series.associate { s -> s.id to FloatArray(s.points.size) } }
val tangentBuffers =
remember(seriesStructure) { series.associate { s -> s.id to FloatArray(s.points.size) } }
val deltasBuffers = remember(seriesStructure) {
series.associate { s ->
s.id to FloatArray(
max(
0,
s.points.size - 1
)
)
}
}
// ── Cached PathEffect for dashed grid ──
val dashEffect = remember(axisConfig.dashedGrid) {
if (axisConfig.dashedGrid) PathEffect.dashPathEffect(floatArrayOf(8f, 6f)) else null
}
// ── Accessibility ──
val baseDescription =
remember(dataSet, a11yConfig) { a11yConfig.chartDescriptionBuilder(dataSet) }
val selectedDescription = remember(selectedPointIndex, series, a11yConfig) {
selectedPointIndex?.let { idx -> a11yConfig.selectedPointDescriptionBuilder(idx, series) }
?: ""
}
val chartDescription = "$baseDescription $selectedDescription".trim()
val tooltipCache = remember { mutableMapOf<String, TextLayoutResult>() }
val linePath = remember { Path() }
val areaPath = remember { Path() }
// ── Animation lifecycle ──
SideEffect { animationEngine.syncAnimatables(series) }
LaunchedEffect(series) {
tooltipCache.clear()
animationEngine.launchEntryAnimations(series, animationConfig, yMin, this)
}
Canvas(
modifier = modifier
.defaultMinSize(minWidth = style.minSize, minHeight = style.minSize)
.semantics(mergeDescendants = true) { contentDescription = chartDescription }
// pointerInput(Unit): never restarts, reads all values via rememberUpdatedState
.pointerInput(Unit) {
if (!crosshairConfig.enabled) return@pointerInput
awaitEachGesture {
val activeSeries = currentSeries
val fp = activeSeries.firstOrNull()?.points ?: return@awaitEachGesture
if (fp.isEmpty()) return@awaitEachGesture
val den = currentDensity
val gap = with(den) { style.labelGap.toPx() }
val rtl = currentIsRtl
val yLabelW = currentMaxYLabelWidth
val cLeft = if (rtl) gap else yLabelW + gap
val cRight = if (rtl) size.width - yLabelW - gap else size.width - gap
val cWidth = cRight - cLeft
val axMin = currentXMin
val axMax = currentXMax
val xRange = axMax - axMin
fun mapTouchX(dataX: Float): Float {
val raw =
if (xRange > 0f) cLeft + (dataX - axMin) / xRange * cWidth else cLeft
return if (rtl) cRight - (raw - cLeft) else raw
}
fun nearest(touchX: Float): Int =
fp.indices.minByOrNull { abs(mapTouchX(fp[it].x) - touchX) } ?: 0
val down = awaitFirstDown(requireUnconsumed = false)
currentOnPointSelected(nearest(down.position.x))
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
val change = event.changes.firstOrNull() ?: break
if (!change.pressed) break
currentOnPointSelected(nearest(change.position.x))
change.consume()
}
currentOnPointSelected(null)
}
}
) {
// ── Pure draw lambda: no state mutations ──
if (series.isEmpty()) return@Canvas
val labelGapPx = style.labelGap.toPx()
// RTL-aware chart area: Y labels flip from left to right
val chartLeft =
if (isRtl) labelGapPx else (if (axisConfig.showYLabels) maxYLabelWidth + labelGapPx else labelGapPx)
val chartRight =
if (isRtl) (if (axisConfig.showYLabels) size.width - maxYLabelWidth - labelGapPx else size.width - labelGapPx) else size.width - labelGapPx
val chartBottom =
size.height - (if (axisConfig.showXLabels) maxXLabelHeight + labelGapPx else labelGapPx)
val chartTop = labelGapPx
val chartWidth = chartRight - chartLeft
val chartHeight = chartBottom - chartTop
val xRange = xMax - xMin
val yRange = yMax - yMin
fun mapX(v: Float): Float {
val raw = if (xRange > 0f) chartLeft + (v - xMin) / xRange * chartWidth else chartLeft
return if (isRtl) chartRight - (raw - chartLeft) else raw
}
fun mapY(v: Float) =
if (yRange > 0f) chartBottom - (v - yMin) / yRange * chartHeight else chartTop
val yAxisX = if (isRtl) chartRight else chartLeft
// ── 1. Grid ──
if (axisConfig.showGrid) {
val gridPx = axisConfig.gridStrokeWidth.toPx()
yTickValues.forEach { v ->
val y = mapY(v)
drawLine(
axisConfig.gridColor,
Offset(chartLeft, y),
Offset(chartRight, y),
strokeWidth = gridPx,
pathEffect = dashEffect
)
}
}
if (axisConfig.showVerticalGrid && firstPoints.size > 1) {
val gridPx = axisConfig.gridStrokeWidth.toPx()
firstPoints.forEach { p ->
val x = mapX(p.x)
drawLine(
axisConfig.gridColor,
Offset(x, chartTop),
Offset(x, chartBottom),
strokeWidth = gridPx,
pathEffect = dashEffect
)
}
}
// ── 2. Axes ──
val axisPx = axisConfig.axisStrokeWidth.toPx()
drawLine(
axisConfig.axisColor,
Offset(yAxisX, chartTop),
Offset(yAxisX, chartBottom),
axisPx
)
drawLine(
axisConfig.axisColor,
Offset(chartLeft, chartBottom),
Offset(chartRight, chartBottom),
axisPx
)
// ── 3. Y labels (RTL: drawn on the right side) ──
if (axisConfig.showYLabels) {
yTickValues.forEachIndexed { i, v ->
if (i < yLabelLayouts.size) {
val layout = yLabelLayouts[i]
val y = mapY(v)
val lx =
if (isRtl) chartRight + labelGapPx else chartLeft - labelGapPx - layout.size.width
drawText(layout, topLeft = Offset(lx, y - layout.size.height / 2f))
}
}
}
// ── 4. Series: area fills, line strokes, dots ──
series.forEachIndexed { si, s ->
val n = s.points.size
if (n == 0) return@forEachIndexed
val keys = keyMatrix[s.id] ?: return@forEachIndexed
val xs = xBuffers[s.id] ?: return@forEachIndexed
val ys = yBuffers[s.id] ?: return@forEachIndexed
val tans = tangentBuffers[s.id] ?: return@forEachIndexed
val delts = deltasBuffers[s.id] ?: return@forEachIndexed
// Fill pre-allocated buffers with animated screen positions
for (i in 0 until n) {
val animY = animationEngine.yAnimatables[keys[i]]?.value ?: 0f
xs[i] = mapX(s.points[i].x)
ys[i] = mapY(animY)
}
if (style.curveType == LineCurveType.MonotoneCubic && n >= 2) {
computeMonotoneTangents(xs, ys, tans, delts, n)
}
// Area fill (one Brush creation per gradient series per frame, acceptable)
if (s.fillAlpha > 0f || s.fillGradientColors.isNotEmpty()) {
areaPath.reset()
areaPath.buildArea(xs, ys, tans, n, chartBottom, style.curveType)
val brush = if (s.fillGradientColors.size >= 2) {
Brush.verticalGradient(
s.fillGradientColors,
startY = chartTop,
endY = chartBottom
)
} else {
Brush.verticalGradient(
listOf(
s.color.copy(alpha = s.fillAlpha),
Color.Transparent
), startY = chartTop, endY = chartBottom
)
}
drawPath(areaPath, brush)
}
// Line stroke: gradient or solid
linePath.reset()
linePath.buildCurve(xs, ys, tans, n, style.curveType)
val strokeStyle = Stroke(width = s.strokeWidth.toPx(), cap = StrokeCap.Round)
if (s.strokeGradientColors.size >= 2) {
drawPath(
linePath,
Brush.horizontalGradient(
s.strokeGradientColors,
startX = xs[0],
endX = xs[n - 1]
),
style = strokeStyle
)
} else {
drawPath(linePath, s.color, style = strokeStyle)
}
if (style.showDots) {
val dotR = style.dotRadius.toPx()
for (i in 0 until n) drawCircle(s.color, dotR, Offset(xs[i], ys[i]))
}
}
// ── 5. X labels ──
if (axisConfig.showXLabels && xLabelLayouts.isNotEmpty()) {
var layoutIdx = 0
firstPoints.forEachIndexed { i, p ->
if (i % xLabelInterval == 0 && layoutIdx < xLabelLayouts.size) {
val layout = xLabelLayouts[layoutIdx++]
drawText(
layout,
topLeft = Offset(
mapX(p.x) - layout.size.width / 2f,
chartBottom + labelGapPx
)
)
}
}
}
// ── 6. Crosshair + tooltip ──
selectedPointIndex?.let { idx ->
val fp = series.firstOrNull() ?: return@let
if (idx !in fp.points.indices) return@let
val crossX = mapX(fp.points[idx].x)
drawLine(
crosshairConfig.lineColor,
Offset(crossX, chartTop),
Offset(crossX, chartBottom),
crosshairConfig.lineWidth.toPx()
)
val dotR = crosshairConfig.dotRadius.toPx()
val borderW = crosshairConfig.dotBorderWidth.toPx()
series.forEach { s ->
if (idx < s.points.size) {
val key = keyMatrix[s.id]?.getOrNull(idx) ?: return@forEach
val animY = animationEngine.yAnimatables[key]?.value ?: s.points[idx].y
val cy = mapY(animY)
drawCircle(crosshairConfig.dotBorderColor, dotR + borderW, Offset(crossX, cy))
drawCircle(s.color, dotR, Offset(crossX, cy))
}
}
if (crosshairConfig.showTooltip) {
val tooltipText = buildString {
series.forEachIndexed { i, s ->
if (idx < s.points.size) {
if (i > 0) append("\n")
append(crosshairConfig.tooltipFormatter(s, s.points[idx]))
}
}
}
val cacheKey = "${idx}_${tooltipText.hashCode()}"
val layout = tooltipCache.getOrPut(cacheKey) {
textMeasurer.measure(
tooltipText,
style = tooltipStyle
)
}
val padPx = crosshairConfig.tooltipPadding.toPx()
val tw = layout.size.width + padPx * 2
val th = layout.size.height + padPx * 2
val margin = 8.dp.toPx()
// RTL: tooltip prefers the left side of the crosshair
val spaceRight = size.width - crossX - margin
val spaceLeft = crossX - margin
val tx = if (isRtl) {
if (spaceLeft >= tw) crossX - margin - tw else crossX + margin
} else {
if (spaceRight >= tw) crossX + margin else crossX - margin - tw
}
val ty = chartTop + margin
drawRoundRect(
crosshairConfig.tooltipBackground,
Offset(tx, ty),
Size(tw, th),
CornerRadius(crosshairConfig.tooltipCornerRadius.toPx())
)
drawText(layout, topLeft = Offset(tx + padPx, ty + padPx))
}
}
}
}
// ==========================================
// 5. DEMO
// ==========================================
@Composable
fun LineChartDemoScreen() {
val months =
listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
val palette = listOf(
Color(0xFF6366F1), Color(0xFFF59E0B), Color(0xFF10B981),
Color(0xFFEF4444), Color(0xFF8B5CF6), Color(0xFFEC4899),
Color(0xFF06B6D4), Color(0xFF84CC16)
)
fun randomSeries(
id: String,
label: String,
base: Int,
variance: Int,
showFill: Boolean
): LineSeries {
val c1 = palette.random()
val c2 = palette.filter { it != c1 }.random()
return LineSeries(
id = id, label = label, color = c1, fillAlpha = if (showFill) 0.10f else 0f,
strokeGradientColors = listOf(c1, c2),
points = months.mapIndexed { i, m ->
LineDataPoint(
i.toFloat(),
(base + (-variance..variance).random()).toFloat(),
m
)
}
)
}
var showFill by remember { mutableStateOf(true) }
var curveType by remember { mutableStateOf(LineCurveType.MonotoneCubic) }
var dataSet by remember {
mutableStateOf(
LineDataSet(
series = listOf(
LineSeries(
"rev", "Revenue", color = Color(0xFF6366F1), fillAlpha = 0.10f,
strokeGradientColors = listOf(Color(0xFF818CF8), Color(0xFF4F46E5)),
points = listOf(
42f,
55f,
48f,
72f,
68f,
85f,
90f,
78f,
95f,
110f,
105f,
120f
)
.mapIndexed { i, v -> LineDataPoint(i.toFloat(), v, months[i]) }),
LineSeries(
"exp", "Expenses", color = Color(0xFFF59E0B), fillAlpha = 0.08f,
strokeGradientColors = listOf(Color(0xFFFBBF24), Color(0xFFD97706)),
points = listOf(38f, 42f, 50f, 45f, 55f, 52f, 60f, 58f, 62f, 65f, 70f, 68f)
.mapIndexed { i, v -> LineDataPoint(i.toFloat(), v, months[i]) })
),
contentDescription = "Monthly Revenue vs Expenses"
)
)
}
var selectedIdx by remember { mutableStateOf<Int?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF8FAFC))
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(440.dp)
.background(Color.White, RoundedCornerShape(24.dp))
.padding(20.dp)
) {
Column(Modifier.fillMaxSize()) {
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
"Revenue & Expenses",
fontSize = 18.sp,
fontWeight = FontWeight.ExtraBold,
color = Color(0xFF111827)
)
Text(
if (selectedIdx != null && selectedIdx!! < months.size) months[selectedIdx!!] else "Drag to explore",
fontSize = 13.sp,
color = Color(0xFF94A3B8),
modifier = Modifier.padding(top = 2.dp)
)
}
Column(horizontalAlignment = Alignment.End) {
dataSet.series.forEach { s ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 2.dp)
) {
Text(
s.label,
fontSize = 11.sp,
color = Color(0xFF6B7280),
modifier = Modifier.padding(end = 6.dp)
)
Canvas(Modifier.size(width = 18.dp, height = 4.dp)) {
val cy = size.height / 2f
if (s.strokeGradientColors.size >= 2) {
drawLine(
Brush.horizontalGradient(s.strokeGradientColors),
Offset(0f, cy),
Offset(size.width, cy),
strokeWidth = size.height,
cap = StrokeCap.Round
)
} else {
drawLine(
s.color,
Offset(0f, cy),
Offset(size.width, cy),
strokeWidth = size.height,
cap = StrokeCap.Round
)
}
}
}
}
}
}
Box(Modifier
.fillMaxSize()
.weight(1f)) {
LineChart(
dataSet = if (showFill) dataSet else dataSet.copy(series = dataSet.series.map {
it.copy(
fillAlpha = 0f
)
}),
modifier = Modifier.fillMaxSize(),
style = LineChartStyle(curveType = curveType),
axisConfig = LineAxisConfig(
yTickCount = 5,
dashedGrid = true,
xLabelFormatter = { months.getOrElse(it.toInt()) { "" } }),
selectedPointIndex = selectedIdx,
onPointSelected = { selectedIdx = it }
)
}
}
}
Spacer(Modifier.height(20.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = {
curveType =
if (curveType == LineCurveType.MonotoneCubic) LineCurveType.Linear else LineCurveType.MonotoneCubic
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF6366F1)),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.weight(1f)
.height(50.dp)
) {
Text(
if (curveType == LineCurveType.MonotoneCubic) "Linear" else "Smooth",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
onClick = { showFill = !showFill },
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFF59E0B)),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.weight(1f)
.height(50.dp)
) {
Text(
if (showFill) "No Fill" else "Area Fill",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = Color(0xFF78350F),
maxLines = 1
)
}
Button(
onClick = {
dataSet = LineDataSet(
series = listOf(
randomSeries("rev", "Revenue", 80, 40, showFill),
randomSeries("exp", "Expenses", 55, 25, showFill)
),
contentDescription = "Monthly Revenue vs Expenses"
)
},
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF111827)),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.weight(1f)
.height(50.dp)
) { Text("Randomize", fontSize = 13.sp, fontWeight = FontWeight.Bold, maxLines = 1) }
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment