Files
eatvio_app/lib/features/insights/presentation/charts/insights_chart_common.dart
2026-11-11 16:05:11 +03:00

181 lines
5.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:math' as math;
import 'package:eatvio_app/features/insights/presentation/widgets/insights_colors.dart';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
/// Точка графика: индекс по оси X (как у Chart.js), подпись `dd.MM` и значение.
///
/// Индекс обязателен: Syncfusion `CategoryAxis` склеивает одинаковые строки
/// (`05.07` несколько раз → одна категория). На вебе labels идут по индексу,
/// поэтому здесь X = index, а дата рисуется через [axisLabelFormatter].
class InsightsPoint {
const InsightsPoint({
required this.index,
required this.label,
required this.value,
});
final int index;
final String label;
final double value;
}
/// Склеивает `labels[]` и `values[]` (по минимальной длине).
List<InsightsPoint> buildPoints(List<String> labels, List<double> values) {
final count = math.min(labels.length, values.length);
return [
for (var i = 0; i < count; i++)
InsightsPoint(index: i, label: labels[i], value: values[i]),
];
}
/// Локальный диапазон оси Y вокруг данных (не от нуля), с запасом под сплайн.
///
/// [clampZero] — не уводить минимум в минус на осях КБЖУ (ккал/граммы ≥ 0).
({double min, double max}) paddedRange(
Iterable<double> values, {
double minPadding = 1,
bool clampZero = false,
}) {
if (values.isEmpty) {
return (min: 0, max: 1);
}
final lowest = values.reduce(math.min);
final highest = values.reduce(math.max);
final padding = math.max((highest - lowest) * 0.2, minPadding);
var min = lowest - padding;
var max = highest + padding;
if (clampZero) {
min = math.max(0, min);
}
if (max <= min) {
max = min + minPadding;
}
return (min: min, max: max);
}
const SplineType insightsSplineType = SplineType.monotonic;
const TextStyle insightsAxisLabelStyle = TextStyle(
fontSize: 11,
color: InsightsColors.subtitle,
);
/// Ось X по индексу точки; подпись — дата из [labels].
///
/// Так даты `05.07`, `06.07`, … не схлопываются и не размножаются, когда на
/// графике несколько серий с одним и тем же набором labels (КБЖУ, замеры).
NumericAxis buildInsightsIndexAxis(List<String> labels) {
final lastIndex = math.max(labels.length - 1, 0).toDouble();
// На узком экране не подписываем каждую точку: ~6 подписей, как авто-тики Chart.js.
final interval = labels.length <= 7
? 1.0
: (labels.length / 6).ceilToDouble();
return NumericAxis(
minimum: 0,
maximum: lastIndex,
interval: interval,
labelStyle: insightsAxisLabelStyle,
majorGridLines: const MajorGridLines(width: 0),
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(color: InsightsColors.border, width: 1),
edgeLabelPlacement: EdgeLabelPlacement.shift,
axisLabelFormatter: (details) {
final index = details.value.round();
if (index < 0 || index >= labels.length) {
return ChartAxisLabel('', details.textStyle);
}
// Промежуточные дробные тики (если Syncfusion их поставит) не подписываем.
if ((details.value - index).abs() > 0.01) {
return ChartAxisLabel('', details.textStyle);
}
return ChartAxisLabel(labels[index], insightsAxisLabelStyle);
},
);
}
NumericAxis buildInsightsValueAxis({
required String unit,
required double min,
required double max,
List<PlotBand> plotBands = const [],
}) {
return NumericAxis(
minimum: min,
maximum: max,
// Доп. воздух, чтобы сплайн не врезался в край plot area.
rangePadding: ChartRangePadding.additional,
labelFormat: '{value} $unit',
labelStyle: insightsAxisLabelStyle,
majorTickLines: const MajorTickLines(size: 0),
axisLine: const AxisLine(width: 0),
majorGridLines: MajorGridLines(
color: InsightsColors.border.withValues(alpha: 0.8),
dashArray: const [4, 4],
),
plotBands: plotBands,
);
}
/// Небольшой внутренний отступ, чтобы кривая и подписи не резались контейнером.
const EdgeInsets insightsChartMargin = EdgeInsets.fromLTRB(0, 8, 0, 4);
TrackballBehavior buildInsightsTrackball() {
return TrackballBehavior(
enable: true,
activationMode: ActivationMode.singleTap,
tooltipDisplayMode: TrackballDisplayMode.groupAllPoints,
lineType: TrackballLineType.vertical,
tooltipSettings: const InteractiveTooltip(),
);
}
MarkerSettings buildInsightsMarker(Color color) {
return MarkerSettings(
isVisible: true,
width: 4,
height: 4,
color: Colors.white,
borderColor: color,
borderWidth: 2,
);
}
LinearGradient buildInsightsAreaGradient(Color color) {
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [color.withValues(alpha: 0.18), color.withValues(alpha: 0)],
);
}
/// Пустое состояние графика (нет данных).
class InsightsChartPlaceholder extends StatelessWidget {
const InsightsChartPlaceholder({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return SizedBox(
height: insightsChartHeight,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: InsightsColors.subtitle,
),
),
),
),
);
}
}