package de.timklge.karooheadwind.screens import android.content.Context import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.graphics.Paint.Align import android.graphics.Path import androidx.annotation.ColorInt import kotlin.math.abs import androidx.core.graphics.createBitmap class LineGraphBuilder(val context: Context) { enum class YAxis { LEFT, RIGHT } data class DataPoint(val x: Float, val y: Float) data class Line( val dataPoints: List, @ColorInt val color: Int, val label: String? = null, val yAxis: YAxis = YAxis.LEFT, // Default to left Y-axis val drawCircles: Boolean = true // Default to true ) private fun isNightMode(): Boolean { val nightModeFlags = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK return nightModeFlags == Configuration.UI_MODE_NIGHT_YES } fun drawLineGraph( width: Int, height: Int, gridWidth: Int, gridHeight: Int, lines: Set, labelProvider: ((Float) -> String) ): Bitmap { val isNightMode = isNightMode() val bitmap = createBitmap(width, height) val canvas = Canvas(bitmap) val backgroundColor = if (isNightMode) Color.BLACK else Color.WHITE val primaryTextColor = if (isNightMode) Color.WHITE else Color.BLACK val secondaryTextColor = if (isNightMode) Color.LTGRAY else Color.DKGRAY // For axes canvas.drawColor(backgroundColor) if (lines.isEmpty() || lines.all { it.dataPoints.isEmpty() }) { val emptyPaint = Paint().apply { color = primaryTextColor textSize = 30f // Increased from 24f textAlign = Align.CENTER isAntiAlias = true } canvas.drawText("No data to display", width / 2f, height / 2f, emptyPaint) return bitmap } val marginTop = 10f val marginBottom = 55f // Increased from 40f var marginRight = 25f // Increased from 20f // Made var, default updated var dataMinX = Float.MAX_VALUE var dataMaxX = Float.MIN_VALUE var dataMinYLeft = Float.MAX_VALUE var dataMaxYLeft = Float.MIN_VALUE var dataMinYRight = Float.MAX_VALUE var dataMaxYRight = Float.MIN_VALUE var hasLeftYAxisData = false var hasRightYAxisData = false var hasData = false lines.forEach { line -> if (line.dataPoints.isNotEmpty()) { hasData = true if (line.yAxis == YAxis.LEFT) { hasLeftYAxisData = true line.dataPoints.forEach { point -> dataMinX = minOf(dataMinX, point.x) dataMaxX = maxOf(dataMaxX, point.x) dataMinYLeft = minOf(dataMinYLeft, point.y) dataMaxYLeft = maxOf(dataMaxYLeft, point.y) } } else { // YAxis.RIGHT hasRightYAxisData = true line.dataPoints.forEach { point -> dataMinX = minOf(dataMinX, point.x) dataMaxX = maxOf(dataMaxX, point.x) dataMinYRight = minOf(dataMinYRight, point.y) dataMaxYRight = maxOf(dataMaxYRight, point.y) } } } } if (!hasData) { val emptyPaint = Paint().apply { color = primaryTextColor textSize = 60f // Increased from 48f textAlign = Align.CENTER isAntiAlias = true } canvas.drawText("No data points", width / 2f, height / 2f, emptyPaint) return bitmap } // Dynamically calculate marginLeft based on Y-axis label widths val yAxisLabelPaint = Paint().apply { textSize = 40f // Increased from 32f isAntiAlias = true } var maxLabelWidthLeft = 0f if (hasLeftYAxisData) { val yLabelStringsLeft = mutableListOf() val numYTicksForCalc = 2 // As used later for drawing Y-axis ticks // Determine Y-axis label strings (mirrors logic from where labels are drawn) if (abs(dataMaxYLeft - dataMinYLeft) < 0.0001f) { yLabelStringsLeft.add( String.format( java.util.Locale.getDefault(), "%.0f", dataMinYLeft ) ) } else { for (i in 0..numYTicksForCalc) { val value = dataMinYLeft + ((dataMaxYLeft - dataMinYLeft) / numYTicksForCalc) * i yLabelStringsLeft.add( String.format( java.util.Locale.getDefault(), "%.0f", value ) ) } } for (labelStr in yLabelStringsLeft) { maxLabelWidthLeft = kotlin.math.max(maxLabelWidthLeft, yAxisLabelPaint.measureText(labelStr)) } } val yAxisTextRightToAxisGap = 18f // Increased from 15f val canvasEdgePadding = 3f // Increased from 5f val dynamicMarginLeft = if (hasLeftYAxisData) maxLabelWidthLeft + yAxisTextRightToAxisGap + canvasEdgePadding else canvasEdgePadding // Dynamically calculate marginRight based on Right Y-axis label widths var maxLabelWidthRight = 0f if (hasRightYAxisData) { val yLabelStringsRight = mutableListOf() val numYTicksForCalc = 2 // As used later for drawing Y-axis ticks if (abs(dataMaxYRight - dataMinYRight) < 0.0001f) { yLabelStringsRight.add( String.format( java.util.Locale.getDefault(), "%.0f", dataMinYRight ) ) } else { for (i in 0..numYTicksForCalc) { val value = dataMinYRight + ((dataMaxYRight - dataMinYRight) / numYTicksForCalc) * i yLabelStringsRight.add( String.format( java.util.Locale.getDefault(), "%.0f", value ) ) } } for (labelStr in yLabelStringsRight) { maxLabelWidthRight = kotlin.math.max(maxLabelWidthRight, yAxisLabelPaint.measureText(labelStr)) } val dynamicMarginRight = maxLabelWidthRight + yAxisTextRightToAxisGap + canvasEdgePadding marginRight = dynamicMarginRight // Update marginRight } val graphWidth = width - dynamicMarginLeft - marginRight val graphHeight = height - marginTop - marginBottom val graphLeft = dynamicMarginLeft val graphTop = marginTop val graphBottom = height - marginBottom val graphRight = width - marginRight // Define graphRight for clarity // Legend properties val legendTextSize = 25f val legendTextColor = primaryTextColor val legendPadding = 5f val legendEntryHeight = 30f val legendColorBoxSize = 24f val legendTextMargin = 5f var effectiveMinX = dataMinX var effectiveMaxX = dataMaxX var effectiveMinYLeft = dataMinYLeft var effectiveMaxYLeft = dataMaxYLeft var effectiveMinYRight = dataMinYRight var effectiveMaxYRight = dataMaxYRight if (dataMinX == dataMaxX) { effectiveMinX -= 1f effectiveMaxX += 1f } else { val paddingX = (dataMaxX - dataMinX) * 0.05f if (paddingX > 0.0001f) { effectiveMinX -= paddingX effectiveMaxX += paddingX } else { effectiveMinX -= 1f effectiveMaxX += 1f } } // Y-axis Left: Adjust effective range based on new rules if (hasLeftYAxisData) { // effectiveMinYLeft is dataMinYLeft, effectiveMaxYLeft is dataMaxYLeft at this point if (abs(dataMaxYLeft - dataMinYLeft) < 0.0001f) { // All Y_Left values are equal val commonValue = dataMinYLeft if (commonValue >= 0f) { effectiveMinYLeft = 0f effectiveMaxYLeft = if (commonValue == 0f) 1f else commonValue + kotlin.math.max( abs(commonValue * 0.1f), 1f ) } else { // commonValue < 0f effectiveMaxYLeft = 0f effectiveMinYLeft = commonValue - kotlin.math.max(abs(commonValue * 0.1f), 1f) } } else { // Y_Left values are not all equal, apply standard 5% padding val paddingYLeft = (dataMaxYLeft - dataMinYLeft) * 0.05f if (paddingYLeft > 0.0001f) { effectiveMinYLeft -= paddingYLeft // equivalent to dataMinYLeft - padding effectiveMaxYLeft += paddingYLeft // equivalent to dataMaxYLeft + padding } else { effectiveMinYLeft -= 1f effectiveMaxYLeft += 1f } } // Safety check: ensure min < max for left Y-axis if (effectiveMinYLeft >= effectiveMaxYLeft) { effectiveMaxYLeft = effectiveMinYLeft + 1f } } // Y-axis Right: Adjust effective range based on new rules if (hasRightYAxisData) { // effectiveMinYRight is dataMinYRight, effectiveMaxYRight is dataMaxYRight at this point if (abs(dataMaxYRight - dataMinYRight) < 0.0001f) { // All Y_Right values are equal val commonValue = dataMinYRight if (commonValue >= 0f) { effectiveMinYRight = 0f effectiveMaxYRight = if (commonValue == 0f) 1f else commonValue + kotlin.math.max( abs(commonValue * 0.1f), 1f ) } else { // commonValue < 0f effectiveMaxYRight = 0f effectiveMinYRight = commonValue - kotlin.math.max(abs(commonValue * 0.1f), 1f) } } else { // Y_Right values are not all equal, apply standard 5% padding val paddingYRight = (dataMaxYRight - dataMinYRight) * 0.05f if (paddingYRight > 0.0001f) { effectiveMinYRight -= paddingYRight effectiveMaxYRight += paddingYRight } else { effectiveMinYRight -= 1f effectiveMaxYRight += 1f } } // Safety check: ensure min < max for right Y-axis if (effectiveMinYRight >= effectiveMaxYRight) { effectiveMaxYRight = effectiveMinYRight + 1f } } val rangeX = if (abs(effectiveMaxX - effectiveMinX) < 0.0001f) 1f else (effectiveMaxX - effectiveMinX) val rangeYLeft = if (!hasLeftYAxisData || abs(effectiveMaxYLeft - effectiveMinYLeft) < 0.0001f) 1f else (effectiveMaxYLeft - effectiveMinYLeft) val rangeYRight = if (!hasRightYAxisData || abs(effectiveMaxYRight - effectiveMinYRight) < 0.0001f) 1f else (effectiveMaxYRight - effectiveMinYRight) fun mapX(originalX: Float): Float { return graphLeft + ((originalX - effectiveMinX) / rangeX) * graphWidth } fun mapYLeft(originalY: Float): Float { return graphBottom - ((originalY - effectiveMinYLeft) / rangeYLeft) * graphHeight } fun mapYRight(originalY: Float): Float { return graphBottom - ((originalY - effectiveMinYRight) / rangeYRight) * graphHeight } val axisPaint = Paint().apply { color = secondaryTextColor strokeWidth = 3f isAntiAlias = true } canvas.drawLine( graphLeft, graphBottom, graphLeft + graphWidth, graphBottom, axisPaint ) // X-axis if (hasLeftYAxisData) { canvas.drawLine(graphLeft, graphTop, graphLeft, graphBottom, axisPaint) // Left Y-axis } if (hasRightYAxisData) { canvas.drawLine( graphRight, // Use graphRight for clarity and consistency graphTop, graphRight, graphBottom, axisPaint ) // Right Y-axis } // Grid line paint val gridLinePaint = Paint().apply { color = if (isNightMode) Color.DKGRAY else Color.LTGRAY // Faint color strokeWidth = 1f isAntiAlias = true } val linePaint = Paint().apply { strokeWidth = 6f style = Paint.Style.STROKE isAntiAlias = true strokeCap = Paint.Cap.ROUND strokeJoin = Paint.Join.ROUND } val textPaint = Paint().apply { color = primaryTextColor textSize = 40f // Increased from 32f isAntiAlias = true } for (line in lines) { if (line.dataPoints.isEmpty()) continue linePaint.color = line.color val path = Path() val firstPoint = line.dataPoints.first() val mapY = if (line.yAxis == YAxis.LEFT) ::mapYLeft else ::mapYRight path.moveTo(mapX(firstPoint.x), mapY(firstPoint.y)) if (line.drawCircles) { canvas.drawCircle( mapX(firstPoint.x), mapY(firstPoint.y), 8f, linePaint.apply { style = Paint.Style.FILL }) } linePaint.style = Paint.Style.STROKE for (i in 1 until line.dataPoints.size) { val point = line.dataPoints[i] path.lineTo(mapX(point.x), mapY(point.y)) if (line.drawCircles) { canvas.drawCircle( mapX(point.x), mapY(point.y), 8f, linePaint.apply { style = Paint.Style.FILL }) } linePaint.style = Paint.Style.STROKE } canvas.drawPath(path, linePaint) } // Draw Left Y-axis ticks and labels if (hasLeftYAxisData) { textPaint.textAlign = Align.RIGHT val numYTicks = if (gridWidth > 15) 2 else 1 if (abs(dataMaxYLeft - dataMinYLeft) > 0.0001f) { for (i in 0..numYTicks) { val value = dataMinYLeft + ((dataMaxYLeft - dataMinYLeft) / numYTicks) * i val yPos = mapYLeft(value) if (yPos >= graphTop - 5f && yPos <= graphBottom + 5f) { canvas.drawLine(graphLeft - 5f, yPos, graphLeft + 5f, yPos, axisPaint) // Draw faint horizontal grid line canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint) canvas.drawText( String.format(java.util.Locale.getDefault(), "%.0f", value), graphLeft - 15f, yPos + (textPaint.textSize / 3), textPaint ) } } } else { val yPos = mapYLeft(dataMinYLeft) canvas.drawLine(graphLeft - 5f, yPos, graphLeft + 5f, yPos, axisPaint) // Draw faint horizontal grid line canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint) canvas.drawText( String.format(java.util.Locale.getDefault(), "%.0f", dataMinYLeft), graphLeft - 15f, yPos + (textPaint.textSize / 3), textPaint ) } } // Draw Right Y-axis ticks and labels if (hasRightYAxisData) { textPaint.textAlign = Align.LEFT val numYTicks = if (gridWidth > 15) 2 else 1 if (abs(dataMaxYRight - dataMinYRight) > 0.0001f) { for (i in 0..numYTicks) { val value = dataMinYRight + ((dataMaxYRight - dataMinYRight) / numYTicks) * i val yPos = mapYRight(value) if (yPos >= graphTop - 5f && yPos <= graphBottom + 5f) { canvas.drawLine( graphRight - 5f, yPos, graphRight + 5f, yPos, axisPaint ) // Draw faint horizontal grid line canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint) canvas.drawText( String.format(java.util.Locale.getDefault(), "%.0f", value), graphRight + 15f, yPos + (textPaint.textSize / 3), textPaint ) } } } else { val yPos = mapYRight(dataMinYRight) canvas.drawLine( graphRight - 5f, yPos, graphRight + 5f, yPos, axisPaint ) // Draw faint horizontal grid line canvas.drawLine(graphLeft, yPos, graphRight, yPos, gridLinePaint) canvas.drawText( String.format(java.util.Locale.getDefault(), "%.0f", dataMinYRight), graphRight + 15f, yPos + (textPaint.textSize / 3), textPaint ) } } // Draw Y zero line (solid, using axisPaint) // This is drawn after faint grid lines from Y-ticks, so it will be on top. // It will not be drawn if it coincides with the X-axis (graphBottom), as X-axis is already solid. val yZeroLinePaint = axisPaint // Use the same paint as other axes for consistency if (hasLeftYAxisData) { // If left Y-axis has data and its range includes 0 if (effectiveMinYLeft <= 0f && effectiveMaxYLeft >= 0f) { val yZeroPos = mapYLeft(0f) // Draw if the zero position is within graph bounds (inclusive top) // and not effectively the same as the X-axis (graphBottom). if (yZeroPos in graphTop..graphBottom && abs(yZeroPos - graphBottom) > 0.1f) { canvas.drawLine(graphLeft, yZeroPos, graphRight, yZeroPos, yZeroLinePaint) } } } else if (hasRightYAxisData) { // Else, if no left Y-axis data, but right Y-axis has data and its range includes 0 if (effectiveMinYRight <= 0f && effectiveMaxYRight >= 0f) { val yZeroPos = mapYRight(0f) // Draw if the zero position is within graph bounds (inclusive top) // and not effectively the same as the X-axis (graphBottom). if (yZeroPos in graphTop..graphBottom && abs(yZeroPos - graphBottom) > 0.1f) { canvas.drawLine(graphLeft, yZeroPos, graphRight, yZeroPos, yZeroLinePaint) } } } textPaint.textAlign = Align.CENTER val numXTicks = if (gridHeight > 15) 3 else 1 if (abs(dataMaxX - dataMinX) > 0.0001f) { for (i in 0..numXTicks) { val value = dataMinX + ((dataMaxX - dataMinX) / numXTicks) * i val xPos = mapX(value) if (xPos >= graphLeft - 5f && xPos <= graphLeft + graphWidth + 5f) { canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint) canvas.drawText(labelProvider(value), xPos, graphBottom + 40f, textPaint) } } } else { val xPos = mapX(dataMinX) canvas.drawLine(xPos, graphBottom - 5f, xPos, graphBottom + 5f, axisPaint) canvas.drawText(labelProvider(dataMinX), xPos, graphBottom + 40f, textPaint) } textPaint.textAlign = Align.CENTER textPaint.color = primaryTextColor // Ensure textPaint color is reset before drawing legend // Draw Legend val legendPaint = Paint().apply { textSize = legendTextSize color = legendTextColor isAntiAlias = true textAlign = Align.LEFT // Important for measuring text width correctly } val legendColorPaint = Paint().apply { style = Paint.Style.FILL isAntiAlias = true } val legendItems = lines.filter { it.label != null } if (legendItems.isNotEmpty()) { var maxLegendLabelWidth = 0f for (item in legendItems) { maxLegendLabelWidth = kotlin.math.max(maxLegendLabelWidth, legendPaint.measureText(item.label!!)) } val legendContentActualLeft = (width - marginRight - legendPadding - legendColorBoxSize - legendTextMargin - maxLegendLabelWidth) val legendContentActualRight = (width - marginRight - legendPadding) // Right edge of the color box val legendContentActualTop = graphTop + legendPadding // Top edge of the first color box val legendContentActualBottom = legendContentActualTop + (legendItems.size - 1) * legendEntryHeight + legendColorBoxSize // Bottom edge of the last color box val legendBgPaint = Paint().apply { color = if (isNightMode) { Color.argb(210, 0, 0, 0) } else { Color.argb(210, 255, 255, 255) } style = Paint.Style.FILL isAntiAlias = true } canvas.drawRoundRect( legendContentActualLeft, legendContentActualTop, legendContentActualRight, legendContentActualBottom, 5f, 5f, legendBgPaint ) } var currentLegendY = graphTop + legendPadding for (line in legendItems) { // Draw color box legendColorPaint.color = line.color canvas.drawRect( width - marginRight - legendPadding - legendColorBoxSize, // left currentLegendY, // top width - marginRight - legendPadding, // right currentLegendY + legendColorBoxSize, // bottom legendColorPaint ) // Draw label text canvas.drawText( line.label!!, width - marginRight - legendPadding - legendColorBoxSize - legendTextMargin - legendPaint.measureText( line.label ), // x: Align text to the left of the color box currentLegendY + legendColorBoxSize / 2 + legendTextSize / 3, // y: Vertically center text with color box legendPaint ) currentLegendY += legendEntryHeight } return bitmap } }