statistical_charts.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366
  1. // tools/statistical_charts.dart
  2. //
  3. // 统计图表:条形图 / 折线图 / 饼图 / 雷达图。
  4. //
  5. // 这一页没有数学计算,只是把用户输入变成坐标,所以**不接 Symja**;
  6. // 数值用 double.tryParse 解析。包进 [ToolScaffold] 后,绘制由「计算」按钮触发,
  7. // 重置按钮也和其它工具一致。图表类型、曲线类型、增删数据行这些操作会换 key
  8. // 让外壳重建并重新计算,这样"点一下切换类型"仍然立刻重画。
  9. import 'package:flutter/material.dart';
  10. import 'package:fl_chart/fl_chart.dart';
  11. import 'tool_kit.dart';
  12. class StatisticalChartsPage extends StatefulWidget {
  13. const StatisticalChartsPage({super.key});
  14. @override
  15. State<StatisticalChartsPage> createState() => _StatisticalChartsPageState();
  16. }
  17. class _StatisticalChartsPageState extends State<StatisticalChartsPage> {
  18. static const List<String> _chartTypes = <String>['条形图', '折线图', '饼图', '雷达图'];
  19. int _type = 0;
  20. /// 折线图是否用光滑曲线(false = 直线)。
  21. bool _curved = true;
  22. /// 输入行:每两格是一行(标签、数值)。整条列表**就地增删**,
  23. /// 对象始终是同一个 —— 外壳靠 `identical(fields)` 判断要不要清空结果。
  24. final List<ToolField> _fields = [];
  25. /// 图表类型 / 曲线类型 / 增删数据行后换 key,让外壳重建并 autoRun 立即重画。
  26. int _runToken = 0;
  27. @override
  28. void initState() {
  29. super.initState();
  30. for (var i = 0; i < 5; i++) {
  31. _addRow(initialLabel: '数据${i + 1}', initialValue: '${(i + 1) * 10}');
  32. }
  33. }
  34. int get _rowCount => _fields.length ~/ 2;
  35. ToolField _labelAt(int i) => _fields[2 * i];
  36. ToolField _valueAt(int i) => _fields[2 * i + 1];
  37. void _addRow({String initialLabel = '新数据', String initialValue = '0'}) {
  38. final n = _rowCount + 1;
  39. final label = ToolField('第 $n 项标签', hint: '数据名', keyboard: TextInputType.text);
  40. final value = ToolField('第 $n 项数值', hint: '例如 10');
  41. label.controller.text = initialLabel;
  42. value.controller.text = initialValue;
  43. _fields.addAll([label, value]);
  44. }
  45. void _onAddRow() => setState(() {
  46. _addRow();
  47. _runToken++;
  48. });
  49. void _onRemoveRow() {
  50. if (_rowCount <= 1) return;
  51. setState(() {
  52. _fields.removeRange(_fields.length - 2, _fields.length);
  53. _runToken++;
  54. });
  55. }
  56. void _selectType(int i) {
  57. if (_type == i) return;
  58. setState(() {
  59. _type = i;
  60. _runToken++;
  61. });
  62. }
  63. void _setCurved(bool value) {
  64. if (_curved == value) return;
  65. setState(() {
  66. _curved = value;
  67. _runToken++;
  68. });
  69. }
  70. Widget _intro() => Column(
  71. crossAxisAlignment: CrossAxisAlignment.start,
  72. children: [
  73. const Text('图表类型'),
  74. const SizedBox(height: 8),
  75. Wrap(
  76. spacing: 8,
  77. runSpacing: 8,
  78. children: [
  79. for (var i = 0; i < _chartTypes.length; i++)
  80. ChoiceChip(
  81. label: Text(_chartTypes[i]),
  82. selected: _type == i,
  83. onSelected: (_) => _selectType(i),
  84. ),
  85. ],
  86. ),
  87. if (_type == 1) ...[
  88. const SizedBox(height: 12),
  89. Row(
  90. children: [
  91. const Text('曲线类型:'),
  92. const SizedBox(width: 8),
  93. ChoiceChip(
  94. label: const Text('直线'),
  95. selected: !_curved,
  96. onSelected: (_) => _setCurved(false),
  97. ),
  98. const SizedBox(width: 8),
  99. ChoiceChip(
  100. label: const Text('光滑曲线'),
  101. selected: _curved,
  102. onSelected: (_) => _setCurved(true),
  103. ),
  104. ],
  105. ),
  106. ],
  107. const SizedBox(height: 12),
  108. Row(
  109. children: [
  110. OutlinedButton.icon(
  111. onPressed: _onAddRow,
  112. icon: const Icon(Icons.add),
  113. label: const Text('添加数据'),
  114. ),
  115. const SizedBox(width: 12),
  116. OutlinedButton.icon(
  117. onPressed: _rowCount > 1 ? _onRemoveRow : null,
  118. icon: const Icon(Icons.remove),
  119. label: const Text('删除数据'),
  120. ),
  121. ],
  122. ),
  123. ],
  124. );
  125. @override
  126. Widget build(BuildContext context) => ToolScaffold(
  127. key: ValueKey<int>(_runToken),
  128. title: '统计图表绘制',
  129. autoRun: true,
  130. fields: _fields,
  131. intro: _intro(),
  132. compute: (session) async {
  133. // 把输入行读成 (标签, 数值)。这里没有数学计算,只是把输入变成坐标,
  134. // 所以解析留在 Dart 侧;非数字就抛中文异常。
  135. final labels = <String>[];
  136. final values = <double>[];
  137. for (var i = 0; i < _rowCount; i++) {
  138. final label = _labelAt(i).text;
  139. final raw = _valueAt(i).text;
  140. if (label.isEmpty && raw.isEmpty) continue;
  141. if (raw.isEmpty) throw Exception('第 ${i + 1} 行的数值不能为空');
  142. final v = double.tryParse(raw);
  143. if (v == null || !v.isFinite) {
  144. throw Exception('第 ${i + 1} 行的「$raw」不是有效数字');
  145. }
  146. values.add(v);
  147. labels.add(label.isEmpty ? '第${i + 1}项' : label);
  148. }
  149. if (values.isEmpty) {
  150. throw Exception('请至少填写一行有效数据');
  151. }
  152. final chart = switch (_type) {
  153. 1 => _lineChart(values, labels),
  154. 2 => _pieChart(values),
  155. 3 => _radarChart(values, labels),
  156. _ => _barChart(values, labels),
  157. };
  158. return <ToolOutput>[
  159. ToolChart(
  160. _chartTypes[_type],
  161. // fl_chart 需要一个确定高度,否则在无界的高度里会报错。
  162. SizedBox(height: 320, child: chart),
  163. ),
  164. ];
  165. },
  166. );
  167. /// 坐标轴的"好看"上限:最大值再留 20% 余量;全零或全负时给个 1,免得轴退化。
  168. static double _niceMax(List<double> values) {
  169. var maxV = values.first;
  170. for (final v in values) {
  171. if (v > maxV) maxV = v;
  172. }
  173. return maxV <= 0 ? 1 : maxV * 1.2;
  174. }
  175. static SideTitles _bottomTitles(List<String> labels) => SideTitles(
  176. showTitles: true,
  177. getTitlesWidget: (value, meta) {
  178. final index = value.toInt();
  179. if (index < 0 || index >= labels.length) {
  180. return const SizedBox.shrink();
  181. }
  182. return Padding(
  183. padding: const EdgeInsets.only(top: 8),
  184. child: Text(
  185. labels[index],
  186. style: const TextStyle(fontSize: 10),
  187. maxLines: 2,
  188. overflow: TextOverflow.ellipsis,
  189. ),
  190. );
  191. },
  192. );
  193. Widget _barChart(List<double> values, List<String> labels) => BarChart(
  194. BarChartData(
  195. alignment: BarChartAlignment.spaceAround,
  196. maxY: _niceMax(values),
  197. barTouchData: BarTouchData(
  198. enabled: true,
  199. touchTooltipData: const BarTouchTooltipData(),
  200. ),
  201. titlesData: FlTitlesData(
  202. show: true,
  203. bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)),
  204. leftTitles: const AxisTitles(
  205. sideTitles: SideTitles(showTitles: true, reservedSize: 40),
  206. ),
  207. ),
  208. gridData: const FlGridData(show: true),
  209. borderData: FlBorderData(show: true),
  210. barGroups: <BarChartGroupData>[
  211. for (var i = 0; i < values.length; i++)
  212. BarChartGroupData(
  213. x: i,
  214. barRods: [
  215. BarChartRodData(
  216. toY: values[i],
  217. color: Colors.primaries[i % Colors.primaries.length],
  218. width: 16,
  219. ),
  220. ],
  221. ),
  222. ],
  223. ),
  224. );
  225. Widget _lineChart(List<double> values, List<String> labels) => LineChart(
  226. LineChartData(
  227. lineTouchData: const LineTouchData(enabled: true),
  228. gridData: const FlGridData(show: true),
  229. titlesData: FlTitlesData(
  230. show: true,
  231. bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)),
  232. leftTitles: const AxisTitles(
  233. sideTitles: SideTitles(showTitles: true, reservedSize: 40),
  234. ),
  235. ),
  236. borderData: FlBorderData(show: true),
  237. minX: 0,
  238. maxX: values.length > 1 ? (values.length - 1).toDouble() : 1,
  239. minY: 0,
  240. maxY: _niceMax(values),
  241. lineBarsData: <LineChartBarData>[
  242. LineChartBarData(
  243. spots: <FlSpot>[
  244. for (var i = 0; i < values.length; i++)
  245. FlSpot(i.toDouble(), values[i]),
  246. ],
  247. isCurved: _curved,
  248. color: Colors.blue,
  249. barWidth: 4,
  250. belowBarData: BarAreaData(show: false),
  251. dotData: FlDotData(
  252. show: true,
  253. getDotPainter: (spot, percent, barData, index) =>
  254. FlDotCirclePainter(
  255. radius: 4,
  256. color: Colors.blue,
  257. strokeWidth: 2,
  258. strokeColor: Colors.white,
  259. ),
  260. ),
  261. ),
  262. ],
  263. ),
  264. );
  265. Widget _pieChart(List<double> values) {
  266. var total = 0.0;
  267. for (final v in values) {
  268. total += v;
  269. }
  270. return PieChart(
  271. PieChartData(
  272. sectionsSpace: 4,
  273. centerSpaceRadius: 40,
  274. sections: <PieChartSectionData>[
  275. for (var i = 0; i < values.length; i++)
  276. PieChartSectionData(
  277. color: Colors.primaries[i % Colors.primaries.length],
  278. value: values[i],
  279. // 百分比:数据本身没问题时才算,全零就留空。
  280. title: total == 0
  281. ? ''
  282. : '${(values[i] / total * 100).toStringAsFixed(1)}%',
  283. radius: 50,
  284. titleStyle: const TextStyle(
  285. fontSize: 12,
  286. fontWeight: FontWeight.bold,
  287. color: Colors.white,
  288. ),
  289. ),
  290. ],
  291. ),
  292. );
  293. }
  294. Widget _radarChart(List<double> values, List<String> labels) {
  295. if (values.length < 3) {
  296. return const Center(
  297. child: Text(
  298. '雷达图需要至少 3 个数据点\n请添加更多数据',
  299. textAlign: TextAlign.center,
  300. style: TextStyle(fontSize: 16, color: Colors.grey),
  301. ),
  302. );
  303. }
  304. return RadarChart(
  305. RadarChartData(
  306. dataSets: <RadarDataSet>[
  307. RadarDataSet(
  308. dataEntries: <RadarEntry>[
  309. for (final v in values) RadarEntry(value: v),
  310. ],
  311. fillColor: Colors.blue.withValues(alpha: 0.3),
  312. borderColor: Colors.blue,
  313. borderWidth: 2,
  314. ),
  315. ],
  316. radarBackgroundColor: Colors.transparent,
  317. radarBorderData: const BorderSide(color: Colors.grey, width: 1),
  318. tickBorderData: const BorderSide(color: Colors.grey, width: 1),
  319. gridBorderData: const BorderSide(color: Colors.grey, width: 1),
  320. ticksTextStyle: const TextStyle(color: Colors.black, fontSize: 10),
  321. tickCount: 5,
  322. getTitle: (index, angle) {
  323. if (index >= 0 && index < labels.length) {
  324. return RadarChartTitle(text: labels[index], angle: angle);
  325. }
  326. return const RadarChartTitle(text: '');
  327. },
  328. titleTextStyle: const TextStyle(
  329. color: Colors.black,
  330. fontSize: 12,
  331. fontWeight: FontWeight.bold,
  332. ),
  333. titlePositionPercentageOffset: 0.15,
  334. ),
  335. swapAnimationDuration: const Duration(milliseconds: 500),
  336. swapAnimationCurve: Curves.easeInOut,
  337. );
  338. }
  339. }