imaNNeo / fl_chart

FL Chart is a highly customizable Flutter chart library that supports Line Chart, Bar Chart, Pie Chart, Scatter Chart, and Radar Chart.
https://flchart.dev
MIT License
6.87k stars 1.78k forks source link

Incorrect calculation of axis interval #1761

Open herna opened 3 weeks ago

herna commented 3 weeks ago

Describe the bug Automatic axis interval is not properly calculated. In the example below I am trying to represent the evolution of a particular value over time.

To Reproduce

import 'package:fl_chart_app/presentation/resources/app_resources.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';

class _LineChart extends StatelessWidget {
  const _LineChart({required this.isShowingMainData});

  final bool isShowingMainData;

  @override
  Widget build(BuildContext context) {
    return LineChart(
      sampleData1,
      duration: const Duration(milliseconds: 250),
    );
  }

  LineChartData get sampleData1 => LineChartData(
        lineTouchData: lineTouchData1,
        gridData: gridData,
        titlesData: titlesData1,
        borderData: borderData,
        lineBarsData: lineBarsData1,
      );

  LineTouchData get lineTouchData1 => LineTouchData(
        handleBuiltInTouches: true,
        touchTooltipData: LineTouchTooltipData(
          getTooltipColor: (touchedSpot) => Colors.blueGrey.withOpacity(0.8),
        ),
      );

  FlTitlesData get titlesData1 => FlTitlesData(
        bottomTitles: AxisTitles(
          sideTitles: bottomTitles,
        ),
        rightTitles: const AxisTitles(
          sideTitles: SideTitles(showTitles: false),
        ),
        topTitles: const AxisTitles(
          sideTitles: SideTitles(showTitles: false),
        ),
        leftTitles: AxisTitles(
          sideTitles: leftTitles(),
        ),
      );

  List<LineChartBarData> get lineBarsData1 => [
        lineChartBarData1_1,
        lineChartBarData1_2,
      ];

  Widget leftTitleWidgets(double value, TitleMeta meta) {
    const style = TextStyle(
      fontWeight: FontWeight.bold,
      fontSize: 14,
    );
    String text;
    switch (value.toInt()) {
      case 1:
        text = '1m';
        break;
      case 2:
        text = '2m';
        break;
      case 3:
        text = '3m';
        break;
      case 4:
        text = '5m';
        break;
      case 5:
        text = '6m';
        break;
      default:
        return Container();
    }

    return Text(text, style: style, textAlign: TextAlign.center);
  }

  SideTitles leftTitles() => SideTitles(
        getTitlesWidget: leftTitleWidgets,
        showTitles: true,
        interval: 1,
        reservedSize: 40,
      );

  Widget bottomTitleWidgets(double value, TitleMeta meta) {
    const style = TextStyle(
      fontWeight: FontWeight.bold,
      fontSize: 16,
    );

    DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(value.toInt());
    DateFormat dateFormat = DateFormat('dd/MM/yyyy');
    Widget text = Text(dateFormat.format(dateTime), style: style);

    return SideTitleWidget(
      axisSide: meta.axisSide,
      fitInside: SideTitleFitInsideData.fromTitleMeta(meta),
      space: 10,
      child: text,
    );
  }

  SideTitles get bottomTitles => SideTitles(
        showTitles: true,
        reservedSize: 32,
        getTitlesWidget: bottomTitleWidgets,
        minIncluded: true,
        maxIncluded: true,
      );

  FlGridData get gridData => const FlGridData(show: false);

  FlBorderData get borderData => FlBorderData(
        show: true,
        border: Border(
          bottom:
              BorderSide(color: AppColors.primary.withOpacity(0.2), width: 4),
          left: const BorderSide(color: Colors.transparent),
          right: const BorderSide(color: Colors.transparent),
          top: const BorderSide(color: Colors.transparent),
        ),
      );

  LineChartBarData get lineChartBarData1_1 {
    List<FlSpot> spots = const [
      FlSpot(1729659600000.0, 1),
      FlSpot(1729573200000.0, 1.5),
      FlSpot(1729291369000.0, 1.4),
      FlSpot(1729262809000.0, 3.4),
      FlSpot(1729227600000.0, 2),
      FlSpot(1729198190000.0, 2.2),
      FlSpot(1729169570000.0, 1.8),
      FlSpot(1729141200000.0, 1.8),
      FlSpot(1729140622000.0, 1.8),
      FlSpot(1729112007000.0, 1.8),
      FlSpot(1729083422000.0, 1.8),
      FlSpot(1729054800000.0, 1.8),
    ];

    return LineChartBarData(
      isCurved: true,
      color: AppColors.contentColorGreen,
      barWidth: 8,
      isStrokeCapRound: true,
      dotData: const FlDotData(show: false),
      belowBarData: BarAreaData(show: false),
      spots: spots,
    );
  }

  LineChartBarData get lineChartBarData1_2 {
    List<FlSpot> spots = const [
      FlSpot(1729659600000.0, 1.2),
      FlSpot(1729573200000.0, 1.6),
      FlSpot(1729291369000.0, 1.5),
      FlSpot(1729262809000.0, 3.6),
      FlSpot(1729227600000.0, 2.3),
      FlSpot(1729198190000.0, 2.6),
      FlSpot(1729169570000.0, 1.9),
      FlSpot(1729141200000.0, 1.9),
      FlSpot(1729140622000.0, 2.2),
      FlSpot(1729112007000.0, 1.9),
      FlSpot(1729083422000.0, 1.2),
      FlSpot(1729054800000.0, 1.4),
    ];

    return LineChartBarData(
      isCurved: true,
      color: AppColors.contentColorBlue,
      barWidth: 8,
      isStrokeCapRound: true,
      dotData: const FlDotData(show: false),
      belowBarData: BarAreaData(show: false),
      spots: spots,
    );
  }
}

class LineChartSample1Test extends StatefulWidget {
  const LineChartSample1Test({super.key});

  @override
  State<StatefulWidget> createState() => LineChartSample1TestState();
}

class LineChartSample1TestState extends State<LineChartSample1Test> {
  late bool isShowingMainData;

  @override
  void initState() {
    super.initState();
    isShowingMainData = true;
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.only(
        right: 20,
      ),
      child: SizedBox(
        height: 500,
        child: Stack(
          children: <Widget>[
            Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: <Widget>[
                const SizedBox(
                  height: 37,
                ),
                const Text(
                  'Monthly Sales',
                  style: TextStyle(
                    color: AppColors.primary,
                    fontSize: 32,
                    fontWeight: FontWeight.bold,
                    letterSpacing: 2,
                  ),
                  textAlign: TextAlign.center,
                ),
                const SizedBox(
                  height: 37,
                ),
                Expanded(
                  child: Padding(
                    padding: const EdgeInsets.only(right: 16, left: 6),
                    child: _LineChart(isShowingMainData: isShowingMainData),
                  ),
                ),
                const SizedBox(
                  height: 10,
                ),
              ],
            ),
            IconButton(
              icon: Icon(
                Icons.refresh,
                color: Colors.white.withOpacity(isShowingMainData ? 1.0 : 0.5),
              ),
              onPressed: () {
                setState(() {
                  isShowingMainData = !isShowingMainData;
                });
              },
            )
          ],
        ),
      ),
    );
  }
}

Screenshots Incorrect behaviour:

https://github.com/user-attachments/assets/1f26e141-2230-47f7-9c1f-9b6ae612f9b8

Correct behaviour after own modifications:

https://github.com/user-attachments/assets/c10c17b0-897b-4d3e-8d78-cd1aa53a6abc

Versions


As you can see, dates in the x-axis overlap, I am just using example "LineChartSample1" in the repo with slight modifications.

Find below the modifications I had to make in order to work as per my needs, just in case you find it useful. Don't take these modifications for granted, this is just intended to prove my point.

In the code below, the value that works is accurateInterval, the original roundInterval(accurateInterval); gives completely wrong number of elements that can fit in the axis (much larger than actual value).

Also notice the pixelPerInterval parameter, you should be able to indicate this some way depending on the length of final titles in the axis. Or it should be automatically calculated based on font size and labels on the axis.

From this:

double getEfficientInterval(
    double axisViewSize,
    double diffInAxis, {
    double pixelPerInterval = 40,
  }) {
    final allowedCount = math.max(axisViewSize ~/ pixelPerInterval, 1);
    if (diffInAxis == 0) {
      return 1;
    }
    final accurateInterval =
        diffInAxis == 0 ? axisViewSize : diffInAxis / allowedCount;
    if (allowedCount <= 2) {
      return accurateInterval;
    }
    return roundInterval(accurateInterval);
  }

To this:

double getEfficientInterval(
    double axisViewSize,
    double diffInAxis, {
    double pixelPerInterval = 125,
  }) {
    final allowedCount = math.max(axisViewSize ~/ pixelPerInterval, 1);
    if (diffInAxis == 0) {
      return 1;
    }
    final accurateInterval =
        diffInAxis == 0 ? axisViewSize : diffInAxis / allowedCount;
    return accurateInterval;
  }

Again I cannot fully understand all calculations so this works as long as minIncluded and maxIncluded are both true. This modification is needed to avoid the overlapping of the titles, specifically for first and last elements.

Function Utils().getBestInitialIntervalValue() doesn't work for this particular case, it always says elements don't overlap but they do all the time.

From this:

Iterable<double> iterateThroughAxis({
    required double min,
    bool minIncluded = true,
    required double max,
    bool maxIncluded = true,
    required double baseLine,
    required double interval,
  }) sync* {
    final initialValue = Utils()
        .getBestInitialIntervalValue(min, max, interval, baseline: baseLine);
    var axisSeek = initialValue;
    final firstPositionOverlapsWithMin = axisSeek == min;
    if (!minIncluded && firstPositionOverlapsWithMin) {
      // If initial value is equal to data minimum,
      // move first label one interval further
      axisSeek += interval;
    }
    final diff = max - min;
    final count = diff ~/ interval;
    final lastPosition = initialValue + (count * interval);
    final lastPositionOverlapsWithMax = lastPosition == max;
    final end =
        !maxIncluded && lastPositionOverlapsWithMax ? max - interval : max;

    final epsilon = interval / 100000;
    if (minIncluded && !firstPositionOverlapsWithMin) {
      // Data minimum shall be included and is not yet covered
      yield min;
    }
    while (axisSeek <= end + epsilon) {
      yield axisSeek;
      axisSeek += interval;
    }
    if (maxIncluded && !lastPositionOverlapsWithMax) {
      yield max;
    }
  }

To this:

Iterable<double> iterateThroughAxis({
    required double min,
    bool minIncluded = true,
    required double max,
    bool maxIncluded = true,
    required double baseLine,
    required double interval,
  }) sync* {
    final initialValue = Utils()
        .getBestInitialIntervalValue(min, max, interval, baseline: baseLine);
    var axisSeek = initialValue;
    final firstPositionOverlapsWithMin = axisSeek == min;
    if (!minIncluded && firstPositionOverlapsWithMin) {
      // If initial value is equal to data minimum,
      // move first label one interval further
      axisSeek += interval;
    }
    final diff = max - min;
    final count = diff ~/ interval;
    final lastPosition = initialValue + (count * interval);
    final lastPositionOverlapsWithMax = lastPosition == max;
    final end =
        !maxIncluded && lastPositionOverlapsWithMax ? max - interval : max;

    final epsilon = interval / 100000;

    if (minIncluded) {
      // Data minimum shall be included and is not yet covered
      yield min;
    }

    axisSeek = min + interval;
    while (axisSeek < end + epsilon) {
      yield axisSeek;
      axisSeek += interval;
    }
    if (maxIncluded) {
      yield max;
    }
  }
herna commented 3 weeks ago

Well, I ended up calculating my own interval as per my needs, automatic interval calculation doesn't take in mind label length and elements overlap.

Another issue is minIncluded and maxIncluded elements doesn't work properly by default so min/second values and max/penultimate values usually overlap:

2024-10-29 13 06 30

This might also happen in the other axis with simpler and shorter labels:

2024-10-29 15 26 04

The problem is the second value calculated by function Utils.getBestInitialIntervalValue() is inserted not matter what, it doesn't take care of the space available in the chart:

  /// Finds the best initial interval value
  ///
  /// If there is a zero point in the axis, we want to have a value that passes through it.
  /// For example if we have -3 to +3, with interval 2. if we start from -3, we get something like this: -3, -1, +1, +3
  /// But the most important point is zero in most cases. with this logic we get this: -2, 0, 2
  double getBestInitialIntervalValue(
    double min,
    double max,
    double interval, {
    double baseline = 0.0,
  }) {
    final diff = baseline - min;
    final mod = diff % interval;
    if ((max - min).abs() <= mod) {
      return min;
    }
    if (mod == 0) {
      return min;
    }
    return min + mod;
  }
ndonkoHenri commented 1 day ago

Facing thesame issue.