478 lines
14 KiB
Dart
Executable File
478 lines
14 KiB
Dart
Executable File
import 'package:flutter/material.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import 'package:uae_stat/domain/use_cases/language.dart';
|
|
import 'package:uae_stat/infrastructure/services/packages/list.dart';
|
|
import 'package:uae_stat/presentation/components/space.dart';
|
|
|
|
class HorizontalBarChart extends ConsumerWidget {
|
|
const HorizontalBarChart(
|
|
this.data, {
|
|
super.key,
|
|
required this.color,
|
|
this.shortlistTopN,
|
|
});
|
|
|
|
final Map<
|
|
String,
|
|
({
|
|
num value,
|
|
String? barEndText,
|
|
})> data;
|
|
final MaterialColor color;
|
|
final int? shortlistTopN;
|
|
|
|
List<Color> get _colorList => [
|
|
color.shade50,
|
|
color.shade300,
|
|
color.shade600,
|
|
color.shade900,
|
|
];
|
|
|
|
void _sortData() {
|
|
// Convert the Map to a List of Map entries
|
|
final entriesList = data.entries.toList();
|
|
|
|
// Sort the list by value in descending order
|
|
entriesList.sort(
|
|
(a, b) => b.value.value.compareTo(a.value.value),
|
|
);
|
|
|
|
// repopulate data
|
|
data.removeWhere((key, value) => true);
|
|
data.addEntries(entriesList);
|
|
}
|
|
|
|
void _shortListData() {
|
|
// Take the first 5 entries
|
|
final top5Entries = Map.fromEntries(
|
|
data.entries.take(shortlistTopN!),
|
|
);
|
|
data.removeWhere(
|
|
(key, value) => !top5Entries.keys.contains(key),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, ref) {
|
|
_sortData();
|
|
if (shortlistTopN != null) _shortListData();
|
|
final titles = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: data.keys
|
|
.map<Widget>(
|
|
(e) => Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsetsDirectional.only(end: 4),
|
|
child: Align(
|
|
alignment: AlignmentDirectional.centerEnd,
|
|
child: Text(
|
|
e,
|
|
textAlign: TextAlign.end,
|
|
style: TextStyle(
|
|
fontFamily: context.translate(
|
|
'Roboto',
|
|
'NotoKufi',
|
|
),
|
|
fontSize: 9,
|
|
color: Colors.black,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.toList()
|
|
..intersperseDivider(
|
|
4.verticalSpace,
|
|
),
|
|
);
|
|
final minVal = data.values.fold<num>(
|
|
double.maxFinite,
|
|
(prev, e) => e.value < prev ? e.value : prev,
|
|
);
|
|
final isAnyBarNegative = minVal < 0;
|
|
final maxVal = data.values.fold<num>(
|
|
double.negativeInfinity,
|
|
(prev, e) => e.value.abs() > prev ? e.value.abs() : prev,
|
|
);
|
|
final totalWidth = maxVal;
|
|
final bars = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: List.generate(
|
|
data.length,
|
|
(i) {
|
|
final e = data.entries.elementAt(i);
|
|
if (!isAnyBarNegative) {
|
|
final bar = Container(
|
|
decoration: BoxDecoration(
|
|
borderRadius: const BorderRadiusDirectional.horizontal(
|
|
end: Radius.circular(10),
|
|
),
|
|
color: _colorList[i % _colorList.length],
|
|
),
|
|
child: FractionallySizedBox(
|
|
widthFactor: e.value.value / totalWidth,
|
|
alignment: AlignmentDirectional.centerStart,
|
|
child: double.infinity.verticalSpace,
|
|
),
|
|
);
|
|
final barSegment = Expanded(
|
|
child: Row(
|
|
children: [
|
|
Flexible(
|
|
child: bar,
|
|
),
|
|
if (e.value.barEndText != null) ...[
|
|
4.horizontalSpace,
|
|
SizedBox(
|
|
width: 40,
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
e.value.barEndText!,
|
|
style: TextStyle(
|
|
fontFamily: context.translate(
|
|
'Roboto',
|
|
'NotoKufi',
|
|
),
|
|
fontSize: 11,
|
|
color: Colors.black,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
return barSegment;
|
|
}
|
|
final isBarNegative = e.value.value < 0;
|
|
final bar = DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: _colorList[i % _colorList.length],
|
|
borderRadius: BorderRadiusDirectional.horizontal(
|
|
end: isBarNegative ? Radius.zero : const Radius.circular(10),
|
|
start: isBarNegative ? const Radius.circular(10) : Radius.zero,
|
|
),
|
|
),
|
|
child: FractionallySizedBox(
|
|
widthFactor: e.value.value.abs() / totalWidth,
|
|
alignment: AlignmentDirectional.centerStart,
|
|
child: double.infinity.verticalSpace,
|
|
),
|
|
);
|
|
final barSegment = isBarNegative
|
|
? Expanded(
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Align(
|
|
alignment: AlignmentDirectional.centerEnd,
|
|
child: bar,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: e.value.barEndText == null
|
|
? const SizedBox.shrink()
|
|
: Row(
|
|
children: [
|
|
4.horizontalSpace,
|
|
Text(
|
|
e.value.barEndText!,
|
|
style: TextStyle(
|
|
fontFamily: context.translate(
|
|
'Roboto',
|
|
'NotoKufi',
|
|
),
|
|
fontSize: 11,
|
|
color: Colors.black,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: Expanded(
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: e.value.barEndText == null
|
|
? const SizedBox.shrink()
|
|
: Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
e.value.barEndText!,
|
|
textAlign: TextAlign.end,
|
|
style: TextStyle(
|
|
fontFamily: context.translate(
|
|
'Roboto',
|
|
'NotoKufi',
|
|
),
|
|
fontSize: 11,
|
|
color: Colors.black,
|
|
),
|
|
),
|
|
4.horizontalSpace,
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Align(
|
|
alignment: AlignmentDirectional.centerStart,
|
|
child: bar,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return barSegment;
|
|
},
|
|
)..intersperseDivider(
|
|
8.verticalSpace,
|
|
),
|
|
);
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: titles,
|
|
),
|
|
if (isAnyBarNegative) const VerticalDivider(),
|
|
Expanded(
|
|
flex: 3,
|
|
child: bars,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class HorizontalGroupedBarChart extends ConsumerWidget {
|
|
const HorizontalGroupedBarChart(
|
|
this.data, {
|
|
super.key,
|
|
this.shortlistTopN,
|
|
});
|
|
|
|
final Map<
|
|
String,
|
|
Map<
|
|
String,
|
|
({
|
|
num value,
|
|
String? barEndText,
|
|
Color barColor,
|
|
})>> data;
|
|
final int? shortlistTopN;
|
|
|
|
void _sortData() {
|
|
// Convert the Map to a List of Map entries
|
|
final entriesList = data.entries.toList();
|
|
|
|
// Sort the list by value in descending order
|
|
entriesList.sort(
|
|
(a, b) {
|
|
final bVal = b.value.values.fold<num>(
|
|
0,
|
|
(prev, e) => e.value > prev ? e.value : prev,
|
|
);
|
|
final aVal = a.value.values.fold<num>(
|
|
0,
|
|
(prev, e) => e.value > prev ? e.value : prev,
|
|
);
|
|
return bVal.compareTo(aVal);
|
|
},
|
|
);
|
|
|
|
// repopulate data
|
|
data.removeWhere((key, value) => true);
|
|
data.addEntries(entriesList);
|
|
}
|
|
|
|
void _shortListData() {
|
|
// Take the first 5 entries
|
|
final top5Entries = Map.fromEntries(
|
|
data.entries.take(shortlistTopN!),
|
|
);
|
|
data.removeWhere(
|
|
(key, value) => !top5Entries.keys.contains(key),
|
|
);
|
|
}
|
|
|
|
num minVal() => data.values.fold<num>(
|
|
double.infinity,
|
|
(prev, e) {
|
|
final localMin = e.values.map((r) => r.value).deriveMinimum()!;
|
|
return localMin < prev ? localMin : prev;
|
|
},
|
|
);
|
|
num maxVal() => data.values.fold<num>(
|
|
double.negativeInfinity,
|
|
(prev, e) {
|
|
final localMax = e.values.map((r) => r.value).deriveMaximum()!;
|
|
return localMax > prev ? localMax : prev;
|
|
},
|
|
);
|
|
|
|
@override
|
|
Widget build(BuildContext context, ref) {
|
|
_sortData();
|
|
if (shortlistTopN != null) _shortListData();
|
|
final legend = Column(
|
|
children: data.values.first.entries
|
|
.map<Widget>(
|
|
(key) => Row(
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: key.value.barColor,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
height: 8,
|
|
width: 8,
|
|
),
|
|
3.horizontalSpace,
|
|
Text(
|
|
key.key,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
.toList()
|
|
..intersperseDivider(2.verticalSpace),
|
|
);
|
|
final titles = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: data.keys
|
|
.map<Widget>(
|
|
(e) => Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsetsDirectional.only(end: 4),
|
|
child: Align(
|
|
alignment: AlignmentDirectional.centerEnd,
|
|
child: Text(
|
|
e,
|
|
textAlign: TextAlign.end,
|
|
style: TextStyle(
|
|
fontFamily: context.translate(
|
|
'Roboto',
|
|
'NotoKufi',
|
|
),
|
|
fontSize: 9,
|
|
color: Colors.black,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
.toList()
|
|
..intersperseDivider(
|
|
4.verticalSpace,
|
|
),
|
|
);
|
|
final minimum = minVal();
|
|
final isAnyBarNegative = minimum < 0;
|
|
final totalWidth = maxVal();
|
|
final graph = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: data.entries.map<Widget>(
|
|
(groupData) {
|
|
final stackedBarGroup = Column(
|
|
children: groupData.value.values.map<Widget>(
|
|
(barData) {
|
|
final barOnly = Container(
|
|
decoration: BoxDecoration(
|
|
color: barData.barColor,
|
|
borderRadius: const BorderRadiusDirectional.horizontal(
|
|
end: Radius.circular(10),
|
|
),
|
|
),
|
|
child: FractionallySizedBox(
|
|
widthFactor: barData.value / totalWidth,
|
|
alignment: AlignmentDirectional.centerStart,
|
|
child: double.infinity.verticalSpace,
|
|
),
|
|
);
|
|
final barWithText = Row(
|
|
children: [
|
|
Flexible(
|
|
child: barOnly,
|
|
),
|
|
if (barData.barEndText != null) ...[
|
|
4.horizontalSpace,
|
|
SizedBox(
|
|
width: 40,
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
barData.barEndText!,
|
|
style: TextStyle(
|
|
fontFamily: context.translate(
|
|
'Roboto',
|
|
'NotoKufi',
|
|
),
|
|
fontSize: 11,
|
|
color: Colors.black,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
return Expanded(child: barWithText);
|
|
},
|
|
).toList()
|
|
..intersperseDivider(4.verticalSpace),
|
|
);
|
|
final group = Expanded(
|
|
child: stackedBarGroup,
|
|
);
|
|
return group;
|
|
},
|
|
).toList()
|
|
..intersperseDivider(
|
|
8.verticalSpace,
|
|
),
|
|
);
|
|
return Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Expanded(
|
|
child: SizedBox.shrink(),
|
|
),
|
|
Expanded(
|
|
flex: 3,
|
|
child: legend,
|
|
),
|
|
],
|
|
),
|
|
6.verticalSpace,
|
|
Expanded(
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: titles,
|
|
),
|
|
if (isAnyBarNegative) const VerticalDivider(),
|
|
Expanded(
|
|
flex: 3,
|
|
child: graph,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|