|
|
@@ -1,692 +1,366 @@
|
|
|
// tools/statistical_charts.dart
|
|
|
+//
|
|
|
+// 统计图表:条形图 / 折线图 / 饼图 / 雷达图。
|
|
|
+//
|
|
|
+// 这一页没有数学计算,只是把用户输入变成坐标,所以**不接 Symja**;
|
|
|
+// 数值用 double.tryParse 解析。包进 [ToolScaffold] 后,绘制由「计算」按钮触发,
|
|
|
+// 重置按钮也和其它工具一致。图表类型、曲线类型、增删数据行这些操作会换 key
|
|
|
+// 让外壳重建并重新计算,这样"点一下切换类型"仍然立刻重画。
|
|
|
import 'package:flutter/material.dart';
|
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
|
|
|
|
+import 'tool_kit.dart';
|
|
|
+
|
|
|
class StatisticalChartsPage extends StatefulWidget {
|
|
|
+ const StatisticalChartsPage({super.key});
|
|
|
+
|
|
|
@override
|
|
|
- _StatisticalChartsPageState createState() => _StatisticalChartsPageState();
|
|
|
+ State<StatisticalChartsPage> createState() => _StatisticalChartsPageState();
|
|
|
}
|
|
|
|
|
|
class _StatisticalChartsPageState extends State<StatisticalChartsPage> {
|
|
|
- int _currentChartIndex = 0;
|
|
|
- final List<String> _chartTypes = ['条形图', '折线图', '饼图', '雷达图'];
|
|
|
-
|
|
|
- // 数据控制器
|
|
|
- final List<TextEditingController> _dataControllers = [];
|
|
|
- final List<TextEditingController> _labelControllers = [];
|
|
|
-
|
|
|
- // 图表数据
|
|
|
- List<double> _dataValues = [];
|
|
|
- List<String> _dataLabels = [];
|
|
|
-
|
|
|
+ static const List<String> _chartTypes = <String>['条形图', '折线图', '饼图', '雷达图'];
|
|
|
+
|
|
|
+ int _type = 0;
|
|
|
+
|
|
|
+ /// 折线图是否用光滑曲线(false = 直线)。
|
|
|
+ bool _curved = true;
|
|
|
+
|
|
|
+ /// 输入行:每两格是一行(标签、数值)。整条列表**就地增删**,
|
|
|
+ /// 对象始终是同一个 —— 外壳靠 `identical(fields)` 判断要不要清空结果。
|
|
|
+ final List<ToolField> _fields = [];
|
|
|
+
|
|
|
+ /// 图表类型 / 曲线类型 / 增删数据行后换 key,让外壳重建并 autoRun 立即重画。
|
|
|
+ int _runToken = 0;
|
|
|
+
|
|
|
@override
|
|
|
void initState() {
|
|
|
super.initState();
|
|
|
- // 初始化默认数据
|
|
|
- _initializeDefaultData();
|
|
|
- }
|
|
|
-
|
|
|
- void _initializeDefaultData() {
|
|
|
- // 清空现有控制器
|
|
|
- _dataControllers.clear();
|
|
|
- _labelControllers.clear();
|
|
|
-
|
|
|
- // 添加默认数据
|
|
|
- for (int i = 0; i < 5; i++) {
|
|
|
- _dataControllers.add(TextEditingController(text: '${(i + 1) * 10}'));
|
|
|
- _labelControllers.add(TextEditingController(text: '数据${i + 1}'));
|
|
|
+ for (var i = 0; i < 5; i++) {
|
|
|
+ _addRow(initialLabel: '数据${i + 1}', initialValue: '${(i + 1) * 10}');
|
|
|
}
|
|
|
-
|
|
|
- _updateChartData();
|
|
|
}
|
|
|
-
|
|
|
- void _updateChartData() {
|
|
|
+
|
|
|
+ int get _rowCount => _fields.length ~/ 2;
|
|
|
+
|
|
|
+ ToolField _labelAt(int i) => _fields[2 * i];
|
|
|
+
|
|
|
+ ToolField _valueAt(int i) => _fields[2 * i + 1];
|
|
|
+
|
|
|
+ void _addRow({String initialLabel = '新数据', String initialValue = '0'}) {
|
|
|
+ final n = _rowCount + 1;
|
|
|
+ final label = ToolField('第 $n 项标签', hint: '数据名', keyboard: TextInputType.text);
|
|
|
+ final value = ToolField('第 $n 项数值', hint: '例如 10');
|
|
|
+ label.controller.text = initialLabel;
|
|
|
+ value.controller.text = initialValue;
|
|
|
+ _fields.addAll([label, value]);
|
|
|
+ }
|
|
|
+
|
|
|
+ void _onAddRow() => setState(() {
|
|
|
+ _addRow();
|
|
|
+ _runToken++;
|
|
|
+ });
|
|
|
+
|
|
|
+ void _onRemoveRow() {
|
|
|
+ if (_rowCount <= 1) return;
|
|
|
setState(() {
|
|
|
- _dataValues = [];
|
|
|
- _dataLabels = [];
|
|
|
-
|
|
|
- for (int i = 0; i < _dataControllers.length; i++) {
|
|
|
- if (_dataControllers[i].text.isNotEmpty &&
|
|
|
- _labelControllers[i].text.isNotEmpty) {
|
|
|
- try {
|
|
|
- double value = double.parse(_dataControllers[i].text);
|
|
|
- _dataValues.add(value);
|
|
|
- _dataLabels.add(_labelControllers[i].text);
|
|
|
- } catch (e) {
|
|
|
- // 忽略无效数据
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
+ _fields.removeRange(_fields.length - 2, _fields.length);
|
|
|
+ _runToken++;
|
|
|
});
|
|
|
}
|
|
|
-
|
|
|
- void _addDataField() {
|
|
|
+
|
|
|
+ void _selectType(int i) {
|
|
|
+ if (_type == i) return;
|
|
|
setState(() {
|
|
|
- _dataControllers.add(TextEditingController(text: '0'));
|
|
|
- _labelControllers.add(TextEditingController(text: '新数据'));
|
|
|
+ _type = i;
|
|
|
+ _runToken++;
|
|
|
});
|
|
|
}
|
|
|
-
|
|
|
- void _removeDataField(int index) {
|
|
|
- if (_dataControllers.length > 1) {
|
|
|
- setState(() {
|
|
|
- _dataControllers.removeAt(index);
|
|
|
- _labelControllers.removeAt(index);
|
|
|
- _updateChartData();
|
|
|
- });
|
|
|
- }
|
|
|
+
|
|
|
+ void _setCurved(bool value) {
|
|
|
+ if (_curved == value) return;
|
|
|
+ setState(() {
|
|
|
+ _curved = value;
|
|
|
+ _runToken++;
|
|
|
+ });
|
|
|
}
|
|
|
-
|
|
|
- Widget _buildDataInputPanel() {
|
|
|
- return Card(
|
|
|
- elevation: 4,
|
|
|
- margin: EdgeInsets.all(16),
|
|
|
- child: Padding(
|
|
|
- padding: EdgeInsets.all(16),
|
|
|
- child: Column(
|
|
|
- crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- '数据输入',
|
|
|
- style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
|
- ),
|
|
|
- SizedBox(height: 16),
|
|
|
- Row(
|
|
|
- children: [
|
|
|
- Expanded(
|
|
|
- flex: 2,
|
|
|
- child: Text(
|
|
|
- '标签',
|
|
|
- style: TextStyle(fontWeight: FontWeight.bold),
|
|
|
- textAlign: TextAlign.center,
|
|
|
- ),
|
|
|
- ),
|
|
|
- SizedBox(width: 16),
|
|
|
- Expanded(
|
|
|
- flex: 2,
|
|
|
- child: Text(
|
|
|
- '数值',
|
|
|
- style: TextStyle(fontWeight: FontWeight.bold),
|
|
|
- textAlign: TextAlign.center,
|
|
|
- ),
|
|
|
- ),
|
|
|
- SizedBox(width: 16),
|
|
|
- Expanded(
|
|
|
- flex: 1,
|
|
|
- child: Text(
|
|
|
- '操作',
|
|
|
- style: TextStyle(fontWeight: FontWeight.bold),
|
|
|
- textAlign: TextAlign.center,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- SizedBox(height: 8),
|
|
|
- ...List.generate(_dataControllers.length, (index) {
|
|
|
- return Padding(
|
|
|
- padding: EdgeInsets.symmetric(vertical: 8),
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- Expanded(
|
|
|
- flex: 2,
|
|
|
- child: TextField(
|
|
|
- controller: _labelControllers[index],
|
|
|
- decoration: InputDecoration(
|
|
|
- hintText: '输入标签',
|
|
|
- border: OutlineInputBorder(),
|
|
|
- contentPadding: EdgeInsets.symmetric(horizontal: 12),
|
|
|
- ),
|
|
|
- onChanged: (value) => _updateChartData(),
|
|
|
- ),
|
|
|
- ),
|
|
|
- SizedBox(width: 16),
|
|
|
- Expanded(
|
|
|
- flex: 2,
|
|
|
- child: TextField(
|
|
|
- controller: _dataControllers[index],
|
|
|
- decoration: InputDecoration(
|
|
|
- hintText: '输入数值',
|
|
|
- border: OutlineInputBorder(),
|
|
|
- contentPadding: EdgeInsets.symmetric(horizontal: 12),
|
|
|
- ),
|
|
|
- keyboardType: TextInputType.number,
|
|
|
- onChanged: (value) => _updateChartData(),
|
|
|
- ),
|
|
|
- ),
|
|
|
- SizedBox(width: 16),
|
|
|
- Expanded(
|
|
|
- flex: 1,
|
|
|
- child: IconButton(
|
|
|
- icon: Icon(Icons.delete, color: Colors.red),
|
|
|
- onPressed: () => _removeDataField(index),
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
+
|
|
|
+ Widget _intro() => Column(
|
|
|
+ crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
+ children: [
|
|
|
+ const Text('图表类型'),
|
|
|
+ const SizedBox(height: 8),
|
|
|
+ Wrap(
|
|
|
+ spacing: 8,
|
|
|
+ runSpacing: 8,
|
|
|
+ children: [
|
|
|
+ for (var i = 0; i < _chartTypes.length; i++)
|
|
|
+ ChoiceChip(
|
|
|
+ label: Text(_chartTypes[i]),
|
|
|
+ selected: _type == i,
|
|
|
+ onSelected: (_) => _selectType(i),
|
|
|
),
|
|
|
- );
|
|
|
- }),
|
|
|
- SizedBox(height: 16),
|
|
|
+ ],
|
|
|
+ ),
|
|
|
+ if (_type == 1) ...[
|
|
|
+ const SizedBox(height: 12),
|
|
|
Row(
|
|
|
children: [
|
|
|
- ElevatedButton(
|
|
|
- onPressed: _addDataField,
|
|
|
- child: Text('添加数据'),
|
|
|
- style: ElevatedButton.styleFrom(
|
|
|
- backgroundColor: Colors.blue,
|
|
|
- foregroundColor: Colors.white,
|
|
|
- ),
|
|
|
+ const Text('曲线类型:'),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ ChoiceChip(
|
|
|
+ label: const Text('直线'),
|
|
|
+ selected: !_curved,
|
|
|
+ onSelected: (_) => _setCurved(false),
|
|
|
),
|
|
|
- SizedBox(width: 16),
|
|
|
- ElevatedButton(
|
|
|
- onPressed: _initializeDefaultData,
|
|
|
- child: Text('重置数据'),
|
|
|
- style: ElevatedButton.styleFrom(
|
|
|
- backgroundColor: Colors.grey,
|
|
|
- foregroundColor: Colors.white,
|
|
|
- ),
|
|
|
+ const SizedBox(width: 8),
|
|
|
+ ChoiceChip(
|
|
|
+ label: const Text('光滑曲线'),
|
|
|
+ selected: _curved,
|
|
|
+ onSelected: (_) => _setCurved(true),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
],
|
|
|
- ),
|
|
|
- ),
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- Widget _buildChartTypeSelector() {
|
|
|
- return Card(
|
|
|
- elevation: 4,
|
|
|
- margin: EdgeInsets.symmetric(horizontal: 16),
|
|
|
- child: Padding(
|
|
|
- padding: EdgeInsets.all(16),
|
|
|
- child: Column(
|
|
|
- crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- '图表类型',
|
|
|
- style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
|
- ),
|
|
|
- SizedBox(height: 12),
|
|
|
- Wrap(
|
|
|
- spacing: 8,
|
|
|
- runSpacing: 8,
|
|
|
- children: List.generate(_chartTypes.length, (index) {
|
|
|
- return ChoiceChip(
|
|
|
- label: Text(_chartTypes[index]),
|
|
|
- selected: _currentChartIndex == index,
|
|
|
- onSelected: (selected) {
|
|
|
- setState(() {
|
|
|
- _currentChartIndex = index;
|
|
|
- });
|
|
|
- },
|
|
|
- selectedColor: Colors.blue,
|
|
|
- labelStyle: TextStyle(
|
|
|
- color: _currentChartIndex == index ? Colors.white : Colors.black,
|
|
|
- ),
|
|
|
- );
|
|
|
- }),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- Widget _buildChart() {
|
|
|
- if (_dataValues.isEmpty) {
|
|
|
- return Container(
|
|
|
- height: 300,
|
|
|
- child: Center(
|
|
|
- child: Text(
|
|
|
- '请输入有效数据',
|
|
|
- style: TextStyle(fontSize: 18, color: Colors.grey),
|
|
|
+ const SizedBox(height: 12),
|
|
|
+ Row(
|
|
|
+ children: [
|
|
|
+ OutlinedButton.icon(
|
|
|
+ onPressed: _onAddRow,
|
|
|
+ icon: const Icon(Icons.add),
|
|
|
+ label: const Text('添加数据'),
|
|
|
+ ),
|
|
|
+ const SizedBox(width: 12),
|
|
|
+ OutlinedButton.icon(
|
|
|
+ onPressed: _rowCount > 1 ? _onRemoveRow : null,
|
|
|
+ icon: const Icon(Icons.remove),
|
|
|
+ label: const Text('删除数据'),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
),
|
|
|
- ),
|
|
|
+ ],
|
|
|
);
|
|
|
- }
|
|
|
-
|
|
|
- switch (_currentChartIndex) {
|
|
|
- case 0: // 条形图
|
|
|
- return _buildBarChart();
|
|
|
- case 1: // 折线图
|
|
|
- return _buildLineChart();
|
|
|
- case 2: // 饼图
|
|
|
- return _buildPieChart();
|
|
|
- case 3: // 雷达图
|
|
|
- return _buildRadarChart();
|
|
|
- default:
|
|
|
- return _buildBarChart();
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- Widget _buildBarChart() {
|
|
|
- return Card(
|
|
|
- elevation: 4,
|
|
|
- margin: EdgeInsets.all(16),
|
|
|
- child: Padding(
|
|
|
- padding: EdgeInsets.all(16),
|
|
|
- child: Column(
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- '条形图',
|
|
|
- style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
|
- ),
|
|
|
- SizedBox(height: 16),
|
|
|
- Container(
|
|
|
- height: 300,
|
|
|
- child: BarChart(
|
|
|
- BarChartData(
|
|
|
- alignment: BarChartAlignment.spaceAround,
|
|
|
- maxY: _dataValues.isEmpty ? 100 : _dataValues.reduce((a, b) => a > b ? a : b) * 1.2,
|
|
|
- barTouchData: BarTouchData(
|
|
|
- enabled: true,
|
|
|
- // 简化 tooltip 配置
|
|
|
- touchTooltipData: BarTouchTooltipData(),
|
|
|
- ),
|
|
|
- titlesData: FlTitlesData(
|
|
|
- show: true,
|
|
|
- bottomTitles: AxisTitles(
|
|
|
- sideTitles: SideTitles(
|
|
|
- showTitles: true,
|
|
|
- getTitlesWidget: (value, meta) {
|
|
|
- int index = value.toInt();
|
|
|
- if (index >= 0 && index < _dataLabels.length) {
|
|
|
- return Padding(
|
|
|
- padding: const EdgeInsets.only(top: 8.0),
|
|
|
- child: Text(
|
|
|
- _dataLabels[index],
|
|
|
- style: TextStyle(fontSize: 10),
|
|
|
- maxLines: 2,
|
|
|
- overflow: TextOverflow.ellipsis,
|
|
|
- ),
|
|
|
- );
|
|
|
- }
|
|
|
- return Text('');
|
|
|
- },
|
|
|
- ),
|
|
|
- ),
|
|
|
- leftTitles: AxisTitles(
|
|
|
- sideTitles: SideTitles(
|
|
|
- showTitles: true,
|
|
|
- reservedSize: 40,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- gridData: FlGridData(show: true),
|
|
|
- borderData: FlBorderData(show: true),
|
|
|
- barGroups: List.generate(_dataValues.length, (index) {
|
|
|
- return BarChartGroupData(
|
|
|
- x: index,
|
|
|
- barRods: [
|
|
|
- BarChartRodData(
|
|
|
- toY: _dataValues[index],
|
|
|
- color: Colors.primaries[index % Colors.primaries.length],
|
|
|
- width: 16,
|
|
|
- ),
|
|
|
- ],
|
|
|
- );
|
|
|
- }),
|
|
|
- ),
|
|
|
- ),
|
|
|
+
|
|
|
+ @override
|
|
|
+ Widget build(BuildContext context) => ToolScaffold(
|
|
|
+ key: ValueKey<int>(_runToken),
|
|
|
+ title: '统计图表绘制',
|
|
|
+ autoRun: true,
|
|
|
+ fields: _fields,
|
|
|
+ intro: _intro(),
|
|
|
+ compute: (session) async {
|
|
|
+ // 把输入行读成 (标签, 数值)。这里没有数学计算,只是把输入变成坐标,
|
|
|
+ // 所以解析留在 Dart 侧;非数字就抛中文异常。
|
|
|
+ final labels = <String>[];
|
|
|
+ final values = <double>[];
|
|
|
+ for (var i = 0; i < _rowCount; i++) {
|
|
|
+ final label = _labelAt(i).text;
|
|
|
+ final raw = _valueAt(i).text;
|
|
|
+ if (label.isEmpty && raw.isEmpty) continue;
|
|
|
+ if (raw.isEmpty) throw Exception('第 ${i + 1} 行的数值不能为空');
|
|
|
+ final v = double.tryParse(raw);
|
|
|
+ if (v == null || !v.isFinite) {
|
|
|
+ throw Exception('第 ${i + 1} 行的「$raw」不是有效数字');
|
|
|
+ }
|
|
|
+ values.add(v);
|
|
|
+ labels.add(label.isEmpty ? '第${i + 1}项' : label);
|
|
|
+ }
|
|
|
+ if (values.isEmpty) {
|
|
|
+ throw Exception('请至少填写一行有效数据');
|
|
|
+ }
|
|
|
+
|
|
|
+ final chart = switch (_type) {
|
|
|
+ 1 => _lineChart(values, labels),
|
|
|
+ 2 => _pieChart(values),
|
|
|
+ 3 => _radarChart(values, labels),
|
|
|
+ _ => _barChart(values, labels),
|
|
|
+ };
|
|
|
+
|
|
|
+ return <ToolOutput>[
|
|
|
+ ToolChart(
|
|
|
+ _chartTypes[_type],
|
|
|
+ // fl_chart 需要一个确定高度,否则在无界的高度里会报错。
|
|
|
+ SizedBox(height: 320, child: chart),
|
|
|
),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
- );
|
|
|
+ ];
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ /// 坐标轴的"好看"上限:最大值再留 20% 余量;全零或全负时给个 1,免得轴退化。
|
|
|
+ static double _niceMax(List<double> values) {
|
|
|
+ var maxV = values.first;
|
|
|
+ for (final v in values) {
|
|
|
+ if (v > maxV) maxV = v;
|
|
|
+ }
|
|
|
+ return maxV <= 0 ? 1 : maxV * 1.2;
|
|
|
}
|
|
|
-
|
|
|
- // 在类顶部添加曲线类型状态变量
|
|
|
-bool _isLineCurved = true;
|
|
|
|
|
|
-Widget _buildLineChart() {
|
|
|
- return Card(
|
|
|
- elevation: 4,
|
|
|
- margin: EdgeInsets.all(16),
|
|
|
- child: Padding(
|
|
|
- padding: EdgeInsets.all(16),
|
|
|
- child: Column(
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- '折线图',
|
|
|
- style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
|
- ),
|
|
|
- SizedBox(height: 16),
|
|
|
-
|
|
|
- // 添加曲线类型选择器
|
|
|
- Container(
|
|
|
- padding: EdgeInsets.symmetric(horizontal: 16),
|
|
|
- child: Row(
|
|
|
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- '曲线类型:',
|
|
|
- style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
|
|
- ),
|
|
|
- Row(
|
|
|
- children: [
|
|
|
- ChoiceChip(
|
|
|
- label: Text('直线'),
|
|
|
- selected: !_isLineCurved,
|
|
|
- onSelected: (selected) {
|
|
|
- setState(() {
|
|
|
- _isLineCurved = false;
|
|
|
- });
|
|
|
- },
|
|
|
- selectedColor: Colors.blue,
|
|
|
- labelStyle: TextStyle(
|
|
|
- color: !_isLineCurved ? Colors.white : Colors.black,
|
|
|
- ),
|
|
|
- ),
|
|
|
- SizedBox(width: 8),
|
|
|
- ChoiceChip(
|
|
|
- label: Text('光滑曲线'),
|
|
|
- selected: _isLineCurved,
|
|
|
- onSelected: (selected) {
|
|
|
- setState(() {
|
|
|
- _isLineCurved = true;
|
|
|
- });
|
|
|
- },
|
|
|
- selectedColor: Colors.blue,
|
|
|
- labelStyle: TextStyle(
|
|
|
- color: _isLineCurved ? Colors.white : Colors.black,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ],
|
|
|
+ static SideTitles _bottomTitles(List<String> labels) => SideTitles(
|
|
|
+ showTitles: true,
|
|
|
+ getTitlesWidget: (value, meta) {
|
|
|
+ final index = value.toInt();
|
|
|
+ if (index < 0 || index >= labels.length) {
|
|
|
+ return const SizedBox.shrink();
|
|
|
+ }
|
|
|
+ return Padding(
|
|
|
+ padding: const EdgeInsets.only(top: 8),
|
|
|
+ child: Text(
|
|
|
+ labels[index],
|
|
|
+ style: const TextStyle(fontSize: 10),
|
|
|
+ maxLines: 2,
|
|
|
+ overflow: TextOverflow.ellipsis,
|
|
|
),
|
|
|
+ );
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ Widget _barChart(List<double> values, List<String> labels) => BarChart(
|
|
|
+ BarChartData(
|
|
|
+ alignment: BarChartAlignment.spaceAround,
|
|
|
+ maxY: _niceMax(values),
|
|
|
+ barTouchData: BarTouchData(
|
|
|
+ enabled: true,
|
|
|
+ touchTooltipData: const BarTouchTooltipData(),
|
|
|
),
|
|
|
- SizedBox(height: 16),
|
|
|
-
|
|
|
- Container(
|
|
|
- height: 300,
|
|
|
- child: LineChart(
|
|
|
- LineChartData(
|
|
|
- lineTouchData: LineTouchData(enabled: true),
|
|
|
- gridData: FlGridData(show: true),
|
|
|
- titlesData: FlTitlesData(
|
|
|
- show: true,
|
|
|
- bottomTitles: AxisTitles(
|
|
|
- sideTitles: SideTitles(
|
|
|
- showTitles: true,
|
|
|
- getTitlesWidget: (value, meta) {
|
|
|
- int index = value.toInt();
|
|
|
- if (index >= 0 && index < _dataLabels.length) {
|
|
|
- return Padding(
|
|
|
- padding: const EdgeInsets.only(top: 8.0),
|
|
|
- child: Text(
|
|
|
- _dataLabels[index],
|
|
|
- style: TextStyle(fontSize: 10),
|
|
|
- ),
|
|
|
- );
|
|
|
- }
|
|
|
- return Text('');
|
|
|
- },
|
|
|
- ),
|
|
|
- ),
|
|
|
- leftTitles: AxisTitles(
|
|
|
- sideTitles: SideTitles(
|
|
|
- showTitles: true,
|
|
|
- reservedSize: 40,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- borderData: FlBorderData(show: true),
|
|
|
- minX: 0,
|
|
|
- maxX: _dataValues.length > 0 ? (_dataValues.length - 1).toDouble() : 1,
|
|
|
- minY: 0,
|
|
|
- maxY: _dataValues.isEmpty ? 100 : _dataValues.reduce((a, b) => a > b ? a : b) * 1.2,
|
|
|
- lineBarsData: [
|
|
|
- LineChartBarData(
|
|
|
- spots: List.generate(_dataValues.length, (index) {
|
|
|
- return FlSpot(index.toDouble(), _dataValues[index]);
|
|
|
- }),
|
|
|
- isCurved: _isLineCurved,
|
|
|
- color: Colors.blue,
|
|
|
- barWidth: 4,
|
|
|
- belowBarData: BarAreaData(show: false),
|
|
|
- dotData: FlDotData(
|
|
|
- show: true,
|
|
|
- getDotPainter: (spot, percent, barData, index) {
|
|
|
- return FlDotCirclePainter(
|
|
|
- radius: 4,
|
|
|
- color: Colors.blue,
|
|
|
- strokeWidth: 2,
|
|
|
- strokeColor: Colors.white,
|
|
|
- );
|
|
|
- },
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
+ titlesData: FlTitlesData(
|
|
|
+ show: true,
|
|
|
+ bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)),
|
|
|
+ leftTitles: const AxisTitles(
|
|
|
+ sideTitles: SideTitles(showTitles: true, reservedSize: 40),
|
|
|
),
|
|
|
),
|
|
|
- ],
|
|
|
- ),
|
|
|
- ),
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
- Widget _buildPieChart() {
|
|
|
- double total = _dataValues.fold(0, (a, b) => a + b);
|
|
|
-
|
|
|
- return Card(
|
|
|
- elevation: 4,
|
|
|
- margin: EdgeInsets.all(16),
|
|
|
- child: Padding(
|
|
|
- padding: EdgeInsets.all(16),
|
|
|
- child: Column(
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- '饼图',
|
|
|
- style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
|
- ),
|
|
|
- SizedBox(height: 16),
|
|
|
- Container(
|
|
|
- height: 300,
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- Expanded(
|
|
|
- flex: 2,
|
|
|
- child: PieChart(
|
|
|
- PieChartData(
|
|
|
- sectionsSpace: 4,
|
|
|
- centerSpaceRadius: 40,
|
|
|
- sections: List.generate(_dataValues.length, (index) {
|
|
|
- return PieChartSectionData(
|
|
|
- color: Colors.primaries[index % Colors.primaries.length],
|
|
|
- value: _dataValues[index],
|
|
|
- title: '${(_dataValues[index] / total * 100).toStringAsFixed(1)}%',
|
|
|
- radius: 50,
|
|
|
- titleStyle: TextStyle(
|
|
|
- fontSize: 12,
|
|
|
- fontWeight: FontWeight.bold,
|
|
|
- color: Colors.white,
|
|
|
- ),
|
|
|
- );
|
|
|
- }),
|
|
|
- ),
|
|
|
- ),
|
|
|
- ),
|
|
|
- Expanded(
|
|
|
- flex: 1,
|
|
|
- child: Column(
|
|
|
- mainAxisAlignment: MainAxisAlignment.center,
|
|
|
- crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
- children: List.generate(_dataValues.length, (index) {
|
|
|
- return Padding(
|
|
|
- padding: EdgeInsets.symmetric(vertical: 4),
|
|
|
- child: Row(
|
|
|
- children: [
|
|
|
- Container(
|
|
|
- width: 12,
|
|
|
- height: 12,
|
|
|
- color: Colors.primaries[index % Colors.primaries.length],
|
|
|
- ),
|
|
|
- SizedBox(width: 8),
|
|
|
- Flexible(
|
|
|
- child: Text(
|
|
|
- _dataLabels[index],
|
|
|
- style: TextStyle(fontSize: 12),
|
|
|
- overflow: TextOverflow.ellipsis,
|
|
|
- ),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
- );
|
|
|
- }),
|
|
|
- ),
|
|
|
+ gridData: const FlGridData(show: true),
|
|
|
+ borderData: FlBorderData(show: true),
|
|
|
+ barGroups: <BarChartGroupData>[
|
|
|
+ for (var i = 0; i < values.length; i++)
|
|
|
+ BarChartGroupData(
|
|
|
+ x: i,
|
|
|
+ barRods: [
|
|
|
+ BarChartRodData(
|
|
|
+ toY: values[i],
|
|
|
+ color: Colors.primaries[i % Colors.primaries.length],
|
|
|
+ width: 16,
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
- ),
|
|
|
],
|
|
|
),
|
|
|
- ),
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
- Widget _buildRadarChart() {
|
|
|
- if (_dataValues.length < 3) {
|
|
|
- return Card(
|
|
|
- elevation: 4,
|
|
|
- margin: EdgeInsets.all(16),
|
|
|
- child: Padding(
|
|
|
- padding: EdgeInsets.all(16),
|
|
|
- child: Column(
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- '雷达图',
|
|
|
- style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
|
+ );
|
|
|
+
|
|
|
+ Widget _lineChart(List<double> values, List<String> labels) => LineChart(
|
|
|
+ LineChartData(
|
|
|
+ lineTouchData: const LineTouchData(enabled: true),
|
|
|
+ gridData: const FlGridData(show: true),
|
|
|
+ titlesData: FlTitlesData(
|
|
|
+ show: true,
|
|
|
+ bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)),
|
|
|
+ leftTitles: const AxisTitles(
|
|
|
+ sideTitles: SideTitles(showTitles: true, reservedSize: 40),
|
|
|
),
|
|
|
- SizedBox(height: 16),
|
|
|
- Container(
|
|
|
- height: 300,
|
|
|
- child: Center(
|
|
|
- child: Text(
|
|
|
- '雷达图需要至少3个数据点\n请添加更多数据',
|
|
|
- textAlign: TextAlign.center,
|
|
|
- style: TextStyle(fontSize: 16, color: Colors.grey),
|
|
|
+ ),
|
|
|
+ borderData: FlBorderData(show: true),
|
|
|
+ minX: 0,
|
|
|
+ maxX: values.length > 1 ? (values.length - 1).toDouble() : 1,
|
|
|
+ minY: 0,
|
|
|
+ maxY: _niceMax(values),
|
|
|
+ lineBarsData: <LineChartBarData>[
|
|
|
+ LineChartBarData(
|
|
|
+ spots: <FlSpot>[
|
|
|
+ for (var i = 0; i < values.length; i++)
|
|
|
+ FlSpot(i.toDouble(), values[i]),
|
|
|
+ ],
|
|
|
+ isCurved: _curved,
|
|
|
+ color: Colors.blue,
|
|
|
+ barWidth: 4,
|
|
|
+ belowBarData: BarAreaData(show: false),
|
|
|
+ dotData: FlDotData(
|
|
|
+ show: true,
|
|
|
+ getDotPainter: (spot, percent, barData, index) =>
|
|
|
+ FlDotCirclePainter(
|
|
|
+ radius: 4,
|
|
|
+ color: Colors.blue,
|
|
|
+ strokeWidth: 2,
|
|
|
+ strokeColor: Colors.white,
|
|
|
),
|
|
|
),
|
|
|
),
|
|
|
],
|
|
|
),
|
|
|
+ );
|
|
|
+
|
|
|
+ Widget _pieChart(List<double> values) {
|
|
|
+ var total = 0.0;
|
|
|
+ for (final v in values) {
|
|
|
+ total += v;
|
|
|
+ }
|
|
|
+ return PieChart(
|
|
|
+ PieChartData(
|
|
|
+ sectionsSpace: 4,
|
|
|
+ centerSpaceRadius: 40,
|
|
|
+ sections: <PieChartSectionData>[
|
|
|
+ for (var i = 0; i < values.length; i++)
|
|
|
+ PieChartSectionData(
|
|
|
+ color: Colors.primaries[i % Colors.primaries.length],
|
|
|
+ value: values[i],
|
|
|
+ // 百分比:数据本身没问题时才算,全零就留空。
|
|
|
+ title: total == 0
|
|
|
+ ? ''
|
|
|
+ : '${(values[i] / total * 100).toStringAsFixed(1)}%',
|
|
|
+ radius: 50,
|
|
|
+ titleStyle: const TextStyle(
|
|
|
+ fontSize: 12,
|
|
|
+ fontWeight: FontWeight.bold,
|
|
|
+ color: Colors.white,
|
|
|
+ ),
|
|
|
+ ),
|
|
|
+ ],
|
|
|
),
|
|
|
);
|
|
|
}
|
|
|
|
|
|
- return Card(
|
|
|
- elevation: 4,
|
|
|
- margin: EdgeInsets.all(16),
|
|
|
- child: Padding(
|
|
|
- padding: EdgeInsets.all(16),
|
|
|
- child: Column(
|
|
|
- children: [
|
|
|
- Text(
|
|
|
- '雷达图',
|
|
|
- style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
|
- ),
|
|
|
- SizedBox(height: 16),
|
|
|
- Container(
|
|
|
- height: 300,
|
|
|
- child: RadarChart(
|
|
|
- RadarChartData(
|
|
|
- dataSets: [
|
|
|
- RadarDataSet(
|
|
|
- dataEntries: List.generate(_dataValues.length, (index) {
|
|
|
- return RadarEntry(
|
|
|
- value: _dataValues[index],
|
|
|
- );
|
|
|
- }),
|
|
|
- fillColor: Colors.blue.withOpacity(0.3),
|
|
|
- borderColor: Colors.blue,
|
|
|
- borderWidth: 2,
|
|
|
- ),
|
|
|
- ],
|
|
|
- radarBackgroundColor: Colors.transparent,
|
|
|
- radarBorderData: BorderSide(color: Colors.grey, width: 1),
|
|
|
- tickBorderData: BorderSide(color: Colors.grey, width: 1),
|
|
|
- gridBorderData: BorderSide(color: Colors.grey, width: 1),
|
|
|
- ticksTextStyle: TextStyle(color: Colors.black, fontSize: 10),
|
|
|
- tickCount: 5,
|
|
|
- getTitle: (index, angle) {
|
|
|
- if (index >= 0 && index < _dataLabels.length) {
|
|
|
- return RadarChartTitle(
|
|
|
- text: _dataLabels[index],
|
|
|
- angle: angle,
|
|
|
- );
|
|
|
- }
|
|
|
- return RadarChartTitle(text: '');
|
|
|
- },
|
|
|
- titleTextStyle: TextStyle(
|
|
|
- color: Colors.black,
|
|
|
- fontSize: 12,
|
|
|
- fontWeight: FontWeight.bold,
|
|
|
- ),
|
|
|
- titlePositionPercentageOffset: 0.15,
|
|
|
- ),
|
|
|
- swapAnimationDuration: Duration(milliseconds: 500),
|
|
|
- swapAnimationCurve: Curves.easeInOut,
|
|
|
- ),
|
|
|
- ),
|
|
|
- SizedBox(height: 16),
|
|
|
- Container(
|
|
|
- height: 20,
|
|
|
- child: Row(
|
|
|
- mainAxisAlignment: MainAxisAlignment.center,
|
|
|
- children: [
|
|
|
- Container(
|
|
|
- width: 12,
|
|
|
- height: 12,
|
|
|
- decoration: BoxDecoration(
|
|
|
- color: Colors.blue.withOpacity(0.3),
|
|
|
- border: Border.all(color: Colors.blue, width: 2),
|
|
|
- ),
|
|
|
- ),
|
|
|
- SizedBox(width: 8),
|
|
|
- Text(
|
|
|
- '数据分布',
|
|
|
- style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
|
- ),
|
|
|
- ],
|
|
|
- ),
|
|
|
+ Widget _radarChart(List<double> values, List<String> labels) {
|
|
|
+ if (values.length < 3) {
|
|
|
+ return const Center(
|
|
|
+ child: Text(
|
|
|
+ '雷达图需要至少 3 个数据点\n请添加更多数据',
|
|
|
+ textAlign: TextAlign.center,
|
|
|
+ style: TextStyle(fontSize: 16, color: Colors.grey),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return RadarChart(
|
|
|
+ RadarChartData(
|
|
|
+ dataSets: <RadarDataSet>[
|
|
|
+ RadarDataSet(
|
|
|
+ dataEntries: <RadarEntry>[
|
|
|
+ for (final v in values) RadarEntry(value: v),
|
|
|
+ ],
|
|
|
+ fillColor: Colors.blue.withValues(alpha: 0.3),
|
|
|
+ borderColor: Colors.blue,
|
|
|
+ borderWidth: 2,
|
|
|
),
|
|
|
],
|
|
|
- ),
|
|
|
- ),
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
- @override
|
|
|
- Widget build(BuildContext context) {
|
|
|
- return Scaffold(
|
|
|
- appBar: AppBar(
|
|
|
- title: Text('统计图表绘制', style: TextStyle(color: Colors.black)),
|
|
|
- backgroundColor: Colors.white,
|
|
|
- elevation: 1,
|
|
|
- ),
|
|
|
- body: SingleChildScrollView(
|
|
|
- child: Column(
|
|
|
- children: [
|
|
|
- _buildChartTypeSelector(),
|
|
|
- _buildDataInputPanel(),
|
|
|
- _buildChart(),
|
|
|
- ],
|
|
|
+ radarBackgroundColor: Colors.transparent,
|
|
|
+ radarBorderData: const BorderSide(color: Colors.grey, width: 1),
|
|
|
+ tickBorderData: const BorderSide(color: Colors.grey, width: 1),
|
|
|
+ gridBorderData: const BorderSide(color: Colors.grey, width: 1),
|
|
|
+ ticksTextStyle: const TextStyle(color: Colors.black, fontSize: 10),
|
|
|
+ tickCount: 5,
|
|
|
+ getTitle: (index, angle) {
|
|
|
+ if (index >= 0 && index < labels.length) {
|
|
|
+ return RadarChartTitle(text: labels[index], angle: angle);
|
|
|
+ }
|
|
|
+ return const RadarChartTitle(text: '');
|
|
|
+ },
|
|
|
+ titleTextStyle: const TextStyle(
|
|
|
+ color: Colors.black,
|
|
|
+ fontSize: 12,
|
|
|
+ fontWeight: FontWeight.bold,
|
|
|
),
|
|
|
+ titlePositionPercentageOffset: 0.15,
|
|
|
),
|
|
|
+ swapAnimationDuration: const Duration(milliseconds: 500),
|
|
|
+ swapAnimationCurve: Curves.easeInOut,
|
|
|
);
|
|
|
}
|
|
|
-
|
|
|
- @override
|
|
|
- void dispose() {
|
|
|
- // 清理控制器
|
|
|
- for (var controller in _dataControllers) {
|
|
|
- controller.dispose();
|
|
|
- }
|
|
|
- for (var controller in _labelControllers) {
|
|
|
- controller.dispose();
|
|
|
- }
|
|
|
- super.dispose();
|
|
|
- }
|
|
|
-}
|
|
|
+}
|