infinum / flutter-charts

Customizable charts library for flutter.
https://pub.dev/packages/charts_painter
MIT License
144 stars 42 forks source link

Chart are displayed incorrectly when axisMin and axisMax are set #94

Open iamiota opened 1 year ago

iamiota commented 1 year ago

First of all, thank you for this amazing library, I really like the idea of using widgets to create charts.

I'm creating a line chart with target line(y: 3.6) and target area(y: 0 ~ 2). I encountered some issues after setting axisMin and axisMax:

WX20230910-125832@2x

1. I have a TargetAreaDecoration from 0 to 2, when axisMin is set to 2, it is incorrectly displayed on the x-axis.

The solution I came up with is to dynamically modify the targetMin and targetMax of the TargetAreaDecoration based on axisMin and axisMax. For example, when axisMin is 1, I would change the TargetAreaDecoration's targetMin from 0 to 1, so it won't be displayed on the x-axis anymore.

2. From the blue area in the graph, it can be observed that the rendering behavior of widgetItemBuilder is inconsistent with that of SparkLineDecoration. The size of widgetItemBuilder is much larger than SparkLineDecoration.

I created a Position Widget within widgetItemBuilder and positioned the point widget using its top property. If the verticalMultiplier parameter can be added to widgetItemBuilder, I can calculate the correct position of the point:

Position(
    bottom: (_mappedValues[data.listIndex][data.itemIndex].max! - axisMin) * verticalMultiplier + 8,
)

3. The target line is also being displayed in the wrong place.

The solution I came up with is similar to the first issue. I dynamically modify verticalMultiplier * (3.6 - axisMin) based on axisMin and axisMax.

Do you have any suggestions? Thank you.

Codes

import 'package:charts_painter/chart.dart';
import 'package:flutter/material.dart';

const double axisWidth = 80.0;

class LineChart extends StatelessWidget {
  final bool useAxis;

  LineChart({Key? key, this.useAxis = false}) : super(key: key);

  final List<List<ChartItem<double>>> _mappedValues = [
    [ChartItem(2.0), ChartItem(5.0), ChartItem(8.0), ChartItem(3.0), ChartItem(6.0)]
  ];

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: MediaQuery.of(context).size.height / 2,
      child: Row(
        children: [
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 32),
            child: AnimatedContainer(
              duration: const Duration(milliseconds: 350),
              width: axisWidth,
              child: DecorationsRenderer(
                [
                  HorizontalAxisDecoration(
                    asFixedDecoration: true,
                    lineWidth: 0,
                    axisStep: 2,
                    showValues: true,
                    endWithChart: false,
                    axisValue: (value) => '$value',
                    legendFontStyle: Theme.of(context).textTheme.bodyMedium,
                    valuesAlign: TextAlign.center,
                    valuesPadding: const EdgeInsets.only(left: -axisWidth, bottom: -10),
                    showLines: false,
                    showTopValue: true,
                  )
                ],
                ChartState<double>(
                  data: ChartData(
                    _mappedValues,
                    axisMin: useAxis ? 2 : null,
                    axisMax: useAxis ? 8 : null,
                    dataStrategy: const DefaultDataStrategy(stackMultipleValues: true),
                  ),
                  itemOptions: WidgetItemOptions(widgetItemBuilder: (data) {
                    return const SizedBox();
                  }),
                  backgroundDecorations: [
                    GridDecoration(
                      showVerticalValues: true,
                      verticalLegendPosition: VerticalLegendPosition.bottom,
                      verticalValuesPadding: const EdgeInsets.only(top: 8.0),
                      verticalAxisStep: 2,
                      gridWidth: 1,
                      textStyle: Theme.of(context).textTheme.labelSmall,
                    ),
                  ],
                ),
              ),
            ),
          ),
          Expanded(
            child: Padding(
              padding: const EdgeInsets.symmetric(vertical: 32),
              child: AnimatedChart<double>(
                width: MediaQuery.of(context).size.width - axisWidth - 8,
                duration: const Duration(milliseconds: 450),
                state: ChartState<double>(
                  data: ChartData(
                    _mappedValues,
                    axisMin: useAxis ? 2 : null,
                    axisMax: useAxis ? 8 : null,
                    dataStrategy: const DefaultDataStrategy(stackMultipleValues: true),
                  ),
                  itemOptions: WidgetItemOptions(widgetItemBuilder: (data) {
                    return Stack(
                      clipBehavior: Clip.none,
                      children: [
                        Container(color: Colors.blue.withOpacity(0.2)),
                        Positioned(
                          top: -24,
                          left: 0,
                          right: 0,
                          child: Column(
                            children: [
                              Center(
                                  child: Text(
                                      _mappedValues[data.listIndex][data.itemIndex].max.toString()))
                            ],
                          ),
                        ),
                        Positioned(
                          top: -5,
                          left: 0,
                          right: 0,
                          child: Column(
                            children: [
                              Center(
                                child: Container(
                                  width: 10,
                                  height: 10,
                                  decoration: BoxDecoration(
                                      color: Theme.of(context).colorScheme.primary,
                                      borderRadius: const BorderRadius.all(Radius.circular(8)),
                                      border: Border.all(
                                          width: 1.4,
                                          color: Theme.of(context).colorScheme.surface)),
                                ),
                              )
                            ],
                          ),
                        ),
                      ],
                    );
                  }),
                  foregroundDecorations: [],
                  backgroundDecorations: [
                    GridDecoration(
                      horizontalAxisStep: 2,
                      showVerticalGrid: false,
                      showVerticalValues: true,
                      verticalLegendPosition: VerticalLegendPosition.bottom,
                      verticalValuesPadding: const EdgeInsets.only(top: 8.0),
                      verticalAxisStep: 1,
                      gridColor: Theme.of(context).colorScheme.outline.withOpacity(0.3),
                      dashArray: [8, 8],
                      gridWidth: 1,
                      textStyle: Theme.of(context).textTheme.labelSmall,
                    ),
                    WidgetDecoration(
                      widgetDecorationBuilder:
                          (context, chartState, itemWidth, verticalMultiplier) {
                        return Padding(
                          padding: chartState.defaultMargin,
                          child: Stack(
                            children: [
                              Positioned(
                                right: 0,
                                left: 0,
                                bottom: verticalMultiplier * 3.6,
                                child: CustomPaint(painter: DashedLinePainter()),
                              ),
                            ],
                          ),
                        );
                      },
                    ),
                    TargetAreaDecoration(
                      targetAreaFillColor: Theme.of(context).colorScheme.error.withOpacity(0.6),
                      targetLineColor: Colors.transparent,
                      lineWidth: 0,
                      targetMax: 2,
                      targetMin: 0,
                    ),
                    SparkLineDecoration(
                      lineWidth: 2,
                      lineColor: Theme.of(context).colorScheme.primary,
                      smoothPoints: true,
                      listIndex: 0,
                    ),
                  ],
                ),
              ),
            ),
          )
        ],
      ),
    );
  }
}

class DashedLinePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    double dashWidth = 8, dashSpace = 8, startX = 0;
    final paint = Paint()
      ..color = Colors.blue
      ..strokeWidth = 1;
    while (startX < size.width) {
      canvas.drawLine(Offset(startX, 0), Offset(startX + dashWidth, 0), paint);
      startX += dashWidth + dashSpace;
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}
lukaknezic commented 1 year ago

Hi @iamiota

Thanks for detailed report. I was able to reproduce it with your code easily and fixing it right now. There is a issue when calculating size for widgets when axisMin is set. I will add some tests to cover this case as well so we can be sure that values are displayed correctly 😄

iamiota commented 1 year ago

@lukaknezic Hi Luka, thank you for your fix.❤️ I have tried the latest code, and the size of the widget is now correct.

However, I have encountered another issue when dynamically modifying the chart data. If _mappedValues, axisMin and axisMax are the props of LineChart, and when the props change according to the following, the chart throws repeat errors: 'BoxConstraints has a negative minimum height.' Additionally, in the video, it can be observed that the animation of the line extends beyond the horizontal axis area. I used ClipRect to clip the AnimatedChartto prevent the line animation from being drawn across the full screen.

final List<List<ChartItem<double>>> _mappedValues = [
   [ChartItem(2.0), ChartItem(5.0), ChartItem(8.0), ChartItem(3.0), ChartItem(6.0)]
];
axisMin: 2
axisMax: 8

/// AnimatedChart's duration: const Duration(milliseconds: 500),
/// If milliseconds = 30, everything is good.
/// data change to

final List<List<ChartItem<double>>> _mappedValues = [
   [ChartItem(32.0), ChartItem(35.0), ChartItem(38.0), ChartItem(33.0), ChartItem(36.0)]
];
axisMin: 32
axisMax: 38

https://github.com/infinum/flutter-charts/assets/7531576/a7c2443b-10f8-463d-a24d-001de75049cf

image
lukaknezic commented 1 year ago

negative minimum height should also be fixed, in same branch 😄