stats: improved histogram axis

This commit is contained in:
Thibault Deckers 2022-06-22 17:14:06 +09:00
parent 358cf901ed
commit dbc8aa6227
3 changed files with 133 additions and 9 deletions

View file

@ -14,6 +14,12 @@ extension ExtraDateTime on DateTime {
bool get isThisMonth => isAtSameMonthAs(DateTime.now());
bool get isThisYear => isAtSameYearAs(DateTime.now());
DateTime get date => DateTime(year, month, day);
DateTime addMonths(int months) => DateTime(year, month + months, day, hour, minute, second, millisecond, microsecond);
DateTime addDays(int days) => DateTime(year, month, day + days, hour, minute, second, millisecond, microsecond);
}
final epoch = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true);

View file

@ -0,0 +1,97 @@
import 'dart:math';
import 'package:aves/model/filters/date.dart';
import 'package:aves/utils/time_utils.dart';
import 'package:charts_flutter/flutter.dart' as charts;
import 'package:intl/intl.dart';
// cf charts.DateTimeTickFormatter factory internals for default formats
class TimeAxisSpec {
final List<charts.TickSpec<DateTime>> tickSpecs;
TimeAxisSpec(this.tickSpecs);
factory TimeAxisSpec.forLevel({
required String locale,
required DateLevel level,
required DateTime first,
required DateTime last,
}) {
switch (level) {
case DateLevel.ymd:
return TimeAxisSpec.days(locale, first, last);
case DateLevel.ym:
return TimeAxisSpec.months(locale, first, last);
case DateLevel.y:
default:
return TimeAxisSpec.years(locale, first, last);
}
}
factory TimeAxisSpec.days(String locale, DateTime first, DateTime last) {
final daysTickLongFormat = DateFormat.MMMd(locale);
final daysTickShortFormat = DateFormat.d(locale);
first = first.date;
last = last.date;
final rangeDays = last.difference(first).inDays;
final delta = max(1, (rangeDays / 5).ceil());
List<charts.TickSpec<DateTime>> ticks = [];
int lastContext = -1;
DateFormat dateFormat;
for (int i = 0; i < rangeDays; i += delta) {
final tickDate = first.addDays(i);
if (lastContext != tickDate.month) {
lastContext = tickDate.month;
dateFormat = daysTickLongFormat;
} else {
dateFormat = daysTickShortFormat;
}
ticks.add(charts.TickSpec<DateTime>(tickDate, label: dateFormat.format(tickDate)));
}
return TimeAxisSpec(ticks);
}
factory TimeAxisSpec.months(String locale, DateTime first, DateTime last) {
final monthsTickLongFormat = DateFormat.yMMM(locale);
final monthsTickShortFormat = DateFormat.MMM(locale);
first = DateTime(first.year, first.month);
last = DateTime(last.year, last.month);
final rangeMonths = last.month - first.month + (last.month < first.month ? 12 : 0);
if (rangeMonths < 12) {
first = first.addMonths(-((12 - rangeMonths) / 2).floor());
}
List<charts.TickSpec<DateTime>> ticks = [];
int lastContext = -1;
DateFormat dateFormat;
for (int i = 0; i < DateTime.monthsPerYear; i += 3) {
final tickDate = first.addMonths(2 + i);
if (lastContext != tickDate.year) {
lastContext = tickDate.year;
dateFormat = monthsTickLongFormat;
} else {
dateFormat = monthsTickShortFormat;
}
ticks.add(charts.TickSpec<DateTime>(tickDate, label: dateFormat.format(tickDate)));
}
return TimeAxisSpec(ticks);
}
factory TimeAxisSpec.years(String locale, DateTime first, DateTime last) {
final dateFormat = DateFormat.y(locale);
final firstYear = first.year;
final lastYear = last.year;
final delta = max(1, ((lastYear - firstYear) / 5).ceil());
List<charts.TickSpec<DateTime>> ticks = [];
for (int year = firstYear; year <= lastYear; year += delta) {
final tickDate = DateTime(year);
ticks.add(charts.TickSpec<DateTime>(tickDate, label: dateFormat.format(tickDate)));
}
return TimeAxisSpec(ticks);
}
}

View file

@ -1,11 +1,14 @@
import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/date.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
import 'package:aves/widgets/stats/date/axis.dart';
import 'package:charts_flutter/flutter.dart' as charts;
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class Histogram extends StatefulWidget {
@ -24,6 +27,7 @@ class Histogram extends StatefulWidget {
class _HistogramState extends State<Histogram> {
DateLevel _level = DateLevel.y;
DateTime? _firstDate, _lastDate;
final Map<DateTime, int> _entryCountPerDate = {};
final ValueNotifier<EntryByDate?> _selection = ValueNotifier(null);
@ -33,20 +37,20 @@ class _HistogramState extends State<Histogram> {
void initState() {
super.initState();
final entries = widget.entries;
final firstDate = entries.firstWhereOrNull((entry) => entry.bestDate != null)?.bestDate;
final lastDate = entries.lastWhereOrNull((entry) => entry.bestDate != null)?.bestDate;
final entriesByDateDescending = List.of(widget.entries)..sort(AvesEntry.compareByDate);
_lastDate = entriesByDateDescending.firstWhereOrNull((entry) => entry.bestDate != null)?.bestDate;
_firstDate = entriesByDateDescending.lastWhereOrNull((entry) => entry.bestDate != null)?.bestDate;
if (lastDate != null && firstDate != null) {
final range = firstDate.difference(lastDate);
if (range > const Duration(days: 1)) {
if (range < const Duration(days: 30)) {
if (_lastDate != null && _firstDate != null) {
final rangeDays = _lastDate!.difference(_firstDate!).inDays;
if (rangeDays > 1) {
if (rangeDays <= 31) {
_level = DateLevel.ymd;
} else if (range < const Duration(days: 365)) {
} else if (rangeDays <= 365) {
_level = DateLevel.ym;
}
final dates = entries.map((entry) => entry.bestDate).whereNotNull();
final dates = entriesByDateDescending.map((entry) => entry.bestDate).whereNotNull();
late DateTime Function(DateTime) groupByKey;
switch (_level) {
case DateLevel.ymd:
@ -84,8 +88,18 @@ class _HistogramState extends State<Histogram> {
),
];
final locale = context.l10n.localeName;
final timeAxisSpec = _firstDate != null && _lastDate != null
? TimeAxisSpec.forLevel(
locale: locale,
level: _level,
first: _firstDate!,
last: _lastDate!,
)
: null;
final axisColor = charts.ColorUtil.fromDartColor(theme.colorScheme.onPrimary.withOpacity(.9));
final measureLineColor = charts.ColorUtil.fromDartColor(theme.colorScheme.onPrimary.withOpacity(.1));
final measureFormat = NumberFormat.decimalPattern(locale);
return Column(
mainAxisSize: MainAxisSize.min,
@ -100,12 +114,17 @@ class _HistogramState extends State<Histogram> {
labelStyle: charts.TextStyleSpec(color: axisColor),
lineStyle: charts.LineStyleSpec(color: axisColor),
),
tickProviderSpec: timeAxisSpec != null && timeAxisSpec.tickSpecs.isNotEmpty ? charts.StaticDateTimeTickProviderSpec(timeAxisSpec.tickSpecs) : null,
),
primaryMeasureAxis: charts.NumericAxisSpec(
renderSpec: charts.GridlineRendererSpec(
labelStyle: charts.TextStyleSpec(color: axisColor),
lineStyle: charts.LineStyleSpec(color: measureLineColor),
),
tickFormatterSpec: charts.BasicNumericTickFormatterSpec((v) {
// localize and hide 0
return (v == null || v == 0) ? '' : measureFormat.format(v);
}),
),
defaultRenderer: charts.BarRendererConfig<DateTime>(),
defaultInteractions: false,
@ -152,6 +171,8 @@ class _HistogramState extends State<Histogram> {
return AnimatedSwitcher(
duration: context.read<DurationsData>().formTransition,
switchInCurve: Curves.easeInOutCubic,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SizeTransition(