patrykandpatrick / vico

A light and extensible chart library for Android.
https://patrykandpatrick.com/vico/wiki
Apache License 2.0
2.07k stars 125 forks source link

LineCartesianLayer.PointProvider bug on multiple line series with custom Shape #832

Open diegoup2 opened 1 month ago

diegoup2 commented 1 month ago

How to reproduce

  1. Create custom LineCartesianLayer.PointProvider
  2. Create custom Shape
  3. Create a CartesianChartHost
  4. Add 2 Line series
  5. Add custom point providers
  6. See result
val Diamond: Shape =
    object : Shape {
        override fun draw(
            context: DrawContext,
            paint: Paint,
            path: Path,
            left: Float,
            top: Float,
            right: Float,
            bottom: Float,
        ) {
            val matrix = Matrix()
            val bounds = RectF()
            path.moveTo(left, top)
            path.lineTo(right, top)
            path.lineTo(right, bottom)
            path.lineTo(left, bottom)
            path.computeBounds(bounds, true)
            matrix.postRotate(45f, bounds.centerX(), bounds.centerY())
            path.transform(matrix)
            path.close()
            context.canvas.drawPath(path, paint)
        }

        @Deprecated(
            "Use `draw`.",
            replaceWith = ReplaceWith("draw(context, paint, path, left, top, right, bottom)"),
        )
        override fun drawShape(
            context: DrawContext,
            paint: Paint,
            path: Path,
            left: Float,
            top: Float,
            right: Float,
            bottom: Float,
        ) {
            draw(context, paint, path, left, top, right, bottom)
        }
    }
val LevelSuperHighAlert = Color(0xFF7C0818)
val LevelVeryHighOrLowAlert = Color(0xFFAA182C)
val LevelHighAlert = Color(0xFFEBAB21)
val LevelElevatedAlert = Color(0xFFF0D800)
val LevelNormalAlert = Color(0xFFF0D800)
val SecondaryBlueGray = Color(0xFF6A88AF)

class CustomPointProvider(private val userDB: UserDB?, val isSystolic: Boolean = false) : LineCartesianLayer.PointProvider {

    private var point: Point? = null
    override fun getPoint(
        entry: LineCartesianLayerModel.Entry,
        seriesIndex: Int,
        extraStore: ExtraStore
    ): LineCartesianLayer.Point? {
        point = Point(getGraphShape(entry.y), sizeDp = 10f)
        return point
    }

    override fun getLargestPoint(extraStore: ExtraStore): Point? {
        return point
    }

    private fun getGraphShape(value: Double): ShapeComponent {
        val useCorrectShape = if (isSystolic) Shape.Pill else Diamond
        val useCorrectColor = getColorForShape(value)
        return ShapeComponent(
            shape = useCorrectShape,
            color = useCorrectColor.toArgb(),
            strokeColor = OchsnerDarkBlue.toArgb(),
            strokeThicknessDp = 0.5f
        )
    }

    private fun getColorForShape(value: Double): Color {
        val correctColor: Color = when (userDB?.getProgramsActive()) {
            1 -> {
                if (isSystolic) {
                    when (value) {
                        in 0.0..139.9 -> LevelNormalAlert
                        in 140.0..159.9 -> LevelElevatedAlert
                        in 160.0..999.9 -> LevelSuperHighAlert
                        else -> SecondaryBlueGray
                    }
                } else {
                    when (value) {
                        in 0.0..89.0 -> LevelNormalAlert
                        in 90.0..104.9 -> LevelElevatedAlert
                        in 105.0..999.9 -> LevelSuperHighAlert
                        else -> SecondaryBlueGray
                    }
                }
            }

            else -> {
                if (userDB?.bloodPressureGoal == "140/90") {
                    if (isSystolic) {
                        when (value) {
                            in 0.0..89.9 -> LevelVeryHighOrLowAlert
                            in 90.0..129.9 -> LevelNormalAlert
                            in 130.0..139.9 -> LevelElevatedAlert
                            in 140.0..179.9 -> LevelHighAlert
                            in 180.0..999.9 -> LevelVeryHighOrLowAlert
                            else -> SecondaryBlueGray
                        }
                    } else {
                        when (value) {
                            in 0.0..39.9 -> LevelVeryHighOrLowAlert
                            in 40.0..79.9 -> LevelNormalAlert
                            in 80.0..89.9 -> LevelElevatedAlert
                            in 90.0..119.9 -> LevelHighAlert
                            in 120.0..999.9 -> LevelVeryHighOrLowAlert
                            else -> SecondaryBlueGray
                        }
                    }
                } else {
                    if (isSystolic) {
                        when (value) {
                            in 0.0..89.9 -> LevelVeryHighOrLowAlert
                            in 90.0..119.9 -> LevelNormalAlert
                            in 120.0..129.9 -> LevelElevatedAlert
                            in 130.0..139.9 -> LevelHighAlert
                            in 140.0..179.9 -> LevelVeryHighOrLowAlert
                            in 180.0..999.9 -> LevelSuperHighAlert
                            else -> SecondaryBlueGray
                        }
                    } else {
                        when (value) {
                            in 0.0..39.0 -> LevelVeryHighOrLowAlert
                            in 40.0..79.9 -> LevelNormalAlert
                            in 80.0..89.9 -> LevelHighAlert
                            in 90.0..119.9 -> LevelVeryHighOrLowAlert
                            in 120.0..999.9 -> LevelSuperHighAlert
                            else -> SecondaryBlueGray
                        }
                    }
                }
            }
        }
        return correctColor
    }
}
 val emptyFormatter = remember {
        CartesianValueFormatter { _, _, _ -> "" }
    }

    val axisValueOverrider = remember {
        AxisValueOverrider.fixed(
            minY = 50.0,
            maxY = 200.0,
        )
    }

    val verticalBox =
        remember(chartState.xValuesTransformed) {
            VerticalBox(
                totalDaysBetweenDates = chartState.totalDaysBetweenDates,
                daysSinceStartDateToTarget = chartState.daysSinceStartDateToTarget,
                box = ShapeComponent(
                    color = BlueGray20.toArgb().copyColor(0.36f),
                    shape = Shape.Rectangle
                )
            )
        }

    val systolicPointProvider = remember(chartState.xValuesTransformed) {
        CustomPointProvider(userDB = chartState.currentUser, isSystolic = true)
    }
    val diastolicPointProvider = remember(chartState.xValuesTransformed) {
        CustomPointProvider(userDB = chartState.currentUser, isSystolic = false)
    }

    LaunchedEffect(chartState.xValuesTransformed) {
        withContext(Dispatchers.Default) {
            if (sysList.isEmpty()) return@withContext
            if (chartState.systolicGoal == "" || chartState.diastolicGoal == "") return@withContext
            if (chartState.systolicGoal.toDoubleOrNull() == null || chartState.diastolicGoal.toDoubleOrNull() == null) return@withContext
            if (chartState.xValuesTransformed.isEmpty()) return@withContext
            modelProducer.runTransaction {
                extras { extraStore ->
                    extraStore[sysLabelTop] = chartState.systolicGoal
                    extraStore[sysLimitKey] = chartState.systolicGoal.toDoubleOrNull() ?: 140.0
                    extraStore[diaLabelTop] = chartState.diastolicGoal
                    extraStore[diaLimitKey] = chartState.diastolicGoal.toDoubleOrNull() ?: 90.0
                    extraStore[xToDateMapKey] = chartState.xValuesTransformed
                }
                lineSeries {
                    series(sysList)
                    series(diaList)
                }
            }
        }
    }

    CartesianChartHost(
        chart = rememberCartesianChart(
            rememberLineCartesianLayer(
                axisValueOverrider = axisValueOverrider,
                lineProvider = LineCartesianLayer.LineProvider.series(
                    rememberLine(
                        fill = LineCartesianLayer.LineFill.single(fill(BlueGray20)),
                        pointProvider = systolicPointProvider
                    ),
                    rememberLine(
                        fill = LineCartesianLayer.LineFill.single(fill(BlueGray20)),
                        pointProvider = diastolicPointProvider
                    )
                )
            ),
            endAxis = rememberEndAxis(
                valueFormatter = emptyFormatter
            ),
            topAxis = rememberTopAxis(
                valueFormatter = emptyFormatter
            ),
            startAxis = rememberStartAxis(
                label = rememberTextComponent(
                    color = OchsnerDarkBlue,
                    typeface = AppTheme.GraphAxisLabel.toGraphicsTypeFace(),
                )
            ),
            bottomAxis = rememberBottomAxis(
                valueFormatter = chartValueFormatter,
                label = rememberTextComponent(
                    color = OchsnerDarkBlue,
                    typeface = AppTheme.GraphAxisLabel.toGraphicsTypeFace(),
                    lineCount = 3,
                    textSize = 10.sp
                ),
                itemPlacer =
                remember {
                    HorizontalAxis.ItemPlacer.default(
                        spacing = 1,
                        shiftExtremeTicks = false,
                        addExtremeLabelPadding = false
                    )
                }
            ),
            getXStep = { 1.0 },
            decorations = listOf(
                rememberHorizontalLine(
                    y = { it.getOrNull(sysLimitKey) ?: 0.0 },
                    line = rememberLineComponent(
                        color = OchLightBlue,
                        thickness = 1.5.dp,
                    ),
                    labelComponent = rememberTextComponent(
                        color = OchLightBlue,
                        margins = Dimensions.of(start = 4.dp),
                        padding = Dimensions.of(8.dp, 2.dp),
                    ),
                    label = { it[sysLabelTop] },
                    verticalLabelPosition = VerticalPosition.Top,
                    horizontalLabelPosition = HorizontalPosition.End,
                ),
                rememberHorizontalLine(
                    y = { it[diaLimitKey] },
                    line = rememberLineComponent(
                        color = OchLightBlue,
                        thickness = 1.5.dp,
                    ),
                    labelComponent = rememberTextComponent(
                        color = OchLightBlue,
                        margins = Dimensions.of(start = 4.dp),
                        padding = Dimensions.of(8.dp, 2.dp),
                    ),
                    label = { it[diaLabelTop] },
                    verticalLabelPosition = VerticalPosition.Top,
                    horizontalLabelPosition = HorizontalPosition.End,
                ),
                verticalBox
            ),
        ),
        modelProducer,
        modifier = Modifier
            .fillMaxWidth()
            .padding(bottom = 16.dp),
        zoomState = rememberVicoZoomState(zoomEnabled = false),
    )

Observed behavior

Screenshot 2024-08-06 at 4 18 13 PM

As you can see in the image, there's an overlap between a ghost Shape.Rectangle and my custom Shape.

Expected behavior

Screenshot 2024-08-06 at 4 19 38 PM

This resolves with changing from custom Diamond Shape to Shape.Rectangle on the LineCartesianLayer.PointProvider

Vico version(s)

2.0.0-alpha.27

Android version(s)

API 34

Additional information

No response

Gowsky commented 1 month ago

Hi @diegoup2, thank you for the report. We've identified the problem with ShapeComponent. It occurs when Matrix transformations are used. We will fix it in the next release.

In the meantime you can work around this easily in your code by calling Path.rewind at the beginning of the draw function.

Also, your custom Shape will stick out of its bounds since the diagonal of a square is longer than its side. This may lead to clipping. You could update the drawing logic to resize the shape correctly, but there’s a built-in way of creating a diamond with correct sizing. It may be quicker to use that.

Shape.cut(allPercent = 50)

Note that you may have to increase the Point size.

This will not be affected by the ShapeComponent bug, as CorneredShape doesn’t use Matrix transformations.

Gowsky commented 1 month ago

Hi @diegoup2, Vico 2.0.0-alpha.28 fixes this bug. Cheers!