/* Copyright 2023 Tusky Contributors * * This file is a part of Tusky. * * This program is free software; you can redistribute it and/or modify it under the terms of the * GNU General Public License as published by the Free Software Foundation; either version 3 of the * License, or (at your option) any later version. * * Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, * see . */ package com.keylesspalace.tusky.view import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.Path import android.graphics.PathMeasure import android.graphics.Rect import android.util.AttributeSet import android.view.View import androidx.annotation.ColorInt import androidx.annotation.Dimension import androidx.core.content.res.use import com.keylesspalace.tusky.R import kotlin.math.max class GraphView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : View(context, attrs, defStyleAttr) { @get:ColorInt @ColorInt var primaryLineColor = 0 @get:ColorInt @ColorInt var secondaryLineColor = 0 @get:Dimension var lineWidth = 0f @get:ColorInt @ColorInt var graphColor = 0 @get:ColorInt @ColorInt var metaColor = 0 private var proportionalTrending = false private lateinit var primaryLinePaint: Paint private lateinit var secondaryLinePaint: Paint private lateinit var primaryCirclePaint: Paint private lateinit var secondaryCirclePaint: Paint private lateinit var graphPaint: Paint private lateinit var metaPaint: Paint private lateinit var sizeRect: Rect private var primaryLinePath: Path = Path() private var secondaryLinePath: Path = Path() var maxTrendingValue: Long = 300 var primaryLineData: List = if (isInEditMode) { listOf( 30, 60, 70, 80, 130, 190, 80 ) } else { listOf( 1, 1, 1, 1, 1, 1, 1 ) } set(value) { field = value.map { max(1, it) } primaryLinePath.reset() invalidate() } var secondaryLineData: List = if (isInEditMode) { listOf( 10, 20, 40, 60, 100, 132, 20 ) } else { listOf( 1, 1, 1, 1, 1, 1, 1 ) } set(value) { field = value.map { max(1, it) } secondaryLinePath.reset() invalidate() } init { initFromXML(attrs) } private fun initFromXML(attr: AttributeSet?) { context.obtainStyledAttributes(attr, R.styleable.GraphView).use { a -> primaryLineColor = context.getColor( a.getResourceId( R.styleable.GraphView_primaryLineColor, R.color.tusky_blue ) ) secondaryLineColor = context.getColor( a.getResourceId( R.styleable.GraphView_secondaryLineColor, R.color.tusky_red ) ) lineWidth = a.getDimensionPixelSize( R.styleable.GraphView_lineWidth, R.dimen.graph_line_thickness ).toFloat() graphColor = context.getColor( a.getResourceId( R.styleable.GraphView_graphColor, R.color.colorBackground ) ) metaColor = context.getColor( a.getResourceId( R.styleable.GraphView_metaColor, R.color.dividerColor ) ) proportionalTrending = a.getBoolean( R.styleable.GraphView_proportionalTrending, proportionalTrending ) } primaryLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = primaryLineColor strokeWidth = lineWidth style = Paint.Style.STROKE } primaryCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = primaryLineColor style = Paint.Style.FILL } secondaryLinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = secondaryLineColor strokeWidth = lineWidth style = Paint.Style.STROKE } secondaryCirclePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = secondaryLineColor style = Paint.Style.FILL } graphPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = graphColor } metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = metaColor strokeWidth = 0f style = Paint.Style.STROKE } } private fun initializeVertices() { sizeRect = Rect(0, 0, width, height) initLine(primaryLineData, primaryLinePath) initLine(secondaryLineData, secondaryLinePath) } private fun initLine(lineData: List, path: Path) { val max = if (proportionalTrending) { maxTrendingValue } else { max(primaryLineData.max(), 1) } val mainRatio = height.toFloat() / max.toFloat() val ratioedData = lineData.map { it.toFloat() * mainRatio } val pointDistance = dataSpacing(ratioedData) /** X coord of the start of this path segment */ var startX = 0F /** Y coord of the start of this path segment */ var startY = 0F /** X coord of the end of this path segment */ var endX: Float /** Y coord of the end of this path segment */ var endY: Float /** X coord of bezier control point #1 */ var controlX1: Float /** X coord of bezier control point #2 */ var controlX2: Float // Draw cubic bezier curves between each pair of points. ratioedData.forEachIndexed { index, magnitude -> val x = pointDistance * index.toFloat() val y = height.toFloat() - magnitude if (index == 0) { path.reset() path.moveTo(x, y) startX = x startY = y } else { endX = x endY = y // X-coord for a control point is placed one third of the distance between the // two points. val offsetX = (endX - startX) / 3 controlX1 = startX + offsetX controlX2 = endX - offsetX path.cubicTo(controlX1, startY, controlX2, endY, x, y) startX = x startY = y } } } private fun dataSpacing(data: List) = width.toFloat() / max(data.size - 1, 1).toFloat() override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) if (primaryLinePath.isEmpty && width > 0) { initializeVertices() } canvas?.apply { drawRect(sizeRect, graphPaint) val pointDistance = dataSpacing(primaryLineData) // Vertical tick marks for (i in 0 until primaryLineData.size + 1) { drawLine( i * pointDistance, height.toFloat(), i * pointDistance, height - (height.toFloat() / 20), metaPaint ) } // X-axis drawLine(0f, height.toFloat(), width.toFloat(), height.toFloat(), metaPaint) // Data lines drawLine( canvas = canvas, linePath = secondaryLinePath, linePaint = secondaryLinePaint, circlePaint = secondaryCirclePaint, lineThickness = lineWidth ) drawLine( canvas = canvas, linePath = primaryLinePath, linePaint = primaryLinePaint, circlePaint = primaryCirclePaint, lineThickness = lineWidth ) } } private fun drawLine( canvas: Canvas, linePath: Path, linePaint: Paint, circlePaint: Paint, lineThickness: Float ) { canvas.apply { drawPath( linePath, linePaint ) val pm = PathMeasure(linePath, false) val coord = floatArrayOf(0f, 0f) pm.getPosTan(pm.length * 1f, coord, null) drawCircle(coord[0], coord[1], lineThickness * 2f, circlePaint) } } }