statistical_charts.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  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: '数据名');
  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 SizedBox(height: 8),
  74. Wrap(
  75. spacing: 8,
  76. runSpacing: 8,
  77. children: [
  78. for (var i = 0; i < _chartTypes.length; i++)
  79. ChoiceChip(
  80. label: Text(_chartTypes[i]),
  81. selected: _type == i,
  82. onSelected: (_) => _selectType(i),
  83. ),
  84. ],
  85. ),
  86. if (_type == 1) ...[
  87. const SizedBox(height: 12),
  88. Row(
  89. children: [
  90. const Text('曲线类型:'),
  91. const SizedBox(width: 8),
  92. ChoiceChip(
  93. label: const Text('直线'),
  94. selected: !_curved,
  95. onSelected: (_) => _setCurved(false),
  96. ),
  97. const SizedBox(width: 8),
  98. ChoiceChip(
  99. label: const Text('光滑曲线'),
  100. selected: _curved,
  101. onSelected: (_) => _setCurved(true),
  102. ),
  103. ],
  104. ),
  105. ],
  106. const SizedBox(height: 12),
  107. Row(
  108. children: [
  109. OutlinedButton.icon(
  110. onPressed: _onAddRow,
  111. icon: const Icon(Icons.add),
  112. label: const Text('添加数据'),
  113. ),
  114. const SizedBox(width: 12),
  115. OutlinedButton.icon(
  116. onPressed: _rowCount > 1 ? _onRemoveRow : null,
  117. icon: const Icon(Icons.remove),
  118. label: const Text('删除数据'),
  119. ),
  120. ],
  121. ),
  122. ],
  123. );
  124. @override
  125. Widget build(BuildContext context) => ToolScaffold(
  126. key: ValueKey<int>(_runToken),
  127. title: '统计图表绘制',
  128. autoRun: true,
  129. fields: _fields,
  130. intro: _intro(),
  131. compute: (session) async {
  132. // 把输入行读成 (标签, 数值)。这里没有数学计算,只是把输入变成坐标,
  133. // 所以解析留在 Dart 侧;非数字就抛中文异常。
  134. final labels = <String>[];
  135. final values = <double>[];
  136. for (var i = 0; i < _rowCount; i++) {
  137. final label = _labelAt(i).text;
  138. final raw = _valueAt(i).text;
  139. if (label.isEmpty && raw.isEmpty) continue;
  140. if (raw.isEmpty) throw Exception('第 ${i + 1} 行的数值不能为空');
  141. final v = double.tryParse(raw);
  142. if (v == null || !v.isFinite) {
  143. throw Exception('第 ${i + 1} 行的「$raw」不是有效数字');
  144. }
  145. values.add(v);
  146. labels.add(label.isEmpty ? '第${i + 1}项' : label);
  147. }
  148. if (values.isEmpty) {
  149. throw Exception('请至少填写一行有效数据');
  150. }
  151. final chart = switch (_type) {
  152. 1 => _lineChart(values, labels),
  153. 2 => _pieChart(values),
  154. 3 => _radarChart(values, labels),
  155. _ => _barChart(values, labels),
  156. };
  157. return <ToolOutput>[
  158. ToolChart(
  159. _chartTypes[_type],
  160. // fl_chart 需要一个确定高度,否则在无界的高度里会报错。
  161. SizedBox(height: 320, child: chart),
  162. ),
  163. ];
  164. },
  165. );
  166. /// 坐标轴的"好看"上限:最大值再留 20% 余量;全零或全负时给个 1,免得轴退化。
  167. static double _niceMax(List<double> values) {
  168. var maxV = values.first;
  169. for (final v in values) {
  170. if (v > maxV) maxV = v;
  171. }
  172. return maxV <= 0 ? 1 : maxV * 1.2;
  173. }
  174. static SideTitles _bottomTitles(List<String> labels) => SideTitles(
  175. showTitles: true,
  176. getTitlesWidget: (value, meta) {
  177. final index = value.toInt();
  178. if (index < 0 || index >= labels.length) {
  179. return const SizedBox.shrink();
  180. }
  181. return Padding(
  182. padding: const EdgeInsets.only(top: 8),
  183. child: Text(
  184. labels[index],
  185. style: const TextStyle(fontSize: 10),
  186. maxLines: 2,
  187. overflow: TextOverflow.ellipsis,
  188. ),
  189. );
  190. },
  191. );
  192. Widget _barChart(List<double> values, List<String> labels) => BarChart(
  193. BarChartData(
  194. alignment: BarChartAlignment.spaceAround,
  195. maxY: _niceMax(values),
  196. barTouchData: BarTouchData(
  197. enabled: true,
  198. touchTooltipData: const BarTouchTooltipData(),
  199. ),
  200. titlesData: FlTitlesData(
  201. show: true,
  202. bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)),
  203. leftTitles: const AxisTitles(
  204. sideTitles: SideTitles(showTitles: true, reservedSize: 40),
  205. ),
  206. ),
  207. gridData: const FlGridData(show: true),
  208. borderData: FlBorderData(show: true),
  209. barGroups: <BarChartGroupData>[
  210. for (var i = 0; i < values.length; i++)
  211. BarChartGroupData(
  212. x: i,
  213. barRods: [
  214. BarChartRodData(
  215. toY: values[i],
  216. color: Colors.primaries[i % Colors.primaries.length],
  217. width: 16,
  218. ),
  219. ],
  220. ),
  221. ],
  222. ),
  223. );
  224. Widget _lineChart(List<double> values, List<String> labels) => LineChart(
  225. LineChartData(
  226. lineTouchData: const LineTouchData(enabled: true),
  227. gridData: const FlGridData(show: true),
  228. titlesData: FlTitlesData(
  229. show: true,
  230. bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)),
  231. leftTitles: const AxisTitles(
  232. sideTitles: SideTitles(showTitles: true, reservedSize: 40),
  233. ),
  234. ),
  235. borderData: FlBorderData(show: true),
  236. minX: 0,
  237. maxX: values.length > 1 ? (values.length - 1).toDouble() : 1,
  238. minY: 0,
  239. maxY: _niceMax(values),
  240. lineBarsData: <LineChartBarData>[
  241. LineChartBarData(
  242. spots: <FlSpot>[
  243. for (var i = 0; i < values.length; i++)
  244. FlSpot(i.toDouble(), values[i]),
  245. ],
  246. isCurved: _curved,
  247. color: Colors.blue,
  248. barWidth: 4,
  249. belowBarData: BarAreaData(show: false),
  250. dotData: FlDotData(
  251. show: true,
  252. getDotPainter: (spot, percent, barData, index) =>
  253. FlDotCirclePainter(
  254. radius: 4,
  255. color: Colors.blue,
  256. strokeWidth: 2,
  257. strokeColor: Colors.white,
  258. ),
  259. ),
  260. ),
  261. ],
  262. ),
  263. );
  264. Widget _pieChart(List<double> values) {
  265. var total = 0.0;
  266. for (final v in values) {
  267. total += v;
  268. }
  269. return PieChart(
  270. PieChartData(
  271. sectionsSpace: 4,
  272. centerSpaceRadius: 40,
  273. sections: <PieChartSectionData>[
  274. for (var i = 0; i < values.length; i++)
  275. PieChartSectionData(
  276. color: Colors.primaries[i % Colors.primaries.length],
  277. value: values[i],
  278. // 百分比:数据本身没问题时才算,全零就留空。
  279. title: total == 0
  280. ? ''
  281. : '${(values[i] / total * 100).toStringAsFixed(1)}%',
  282. radius: 50,
  283. titleStyle: const TextStyle(
  284. fontSize: 12,
  285. fontWeight: FontWeight.bold,
  286. color: Colors.white,
  287. ),
  288. ),
  289. ],
  290. ),
  291. );
  292. }
  293. Widget _radarChart(List<double> values, List<String> labels) {
  294. if (values.length < 3) {
  295. return const Center(
  296. child: Text(
  297. '雷达图需要至少 3 个数据点\n请添加更多数据',
  298. textAlign: TextAlign.center,
  299. style: TextStyle(fontSize: 16, color: Colors.grey),
  300. ),
  301. );
  302. }
  303. return RadarChart(
  304. RadarChartData(
  305. dataSets: <RadarDataSet>[
  306. RadarDataSet(
  307. dataEntries: <RadarEntry>[
  308. for (final v in values) RadarEntry(value: v),
  309. ],
  310. fillColor: Colors.blue.withValues(alpha: 0.3),
  311. borderColor: Colors.blue,
  312. borderWidth: 2,
  313. ),
  314. ],
  315. radarBackgroundColor: Colors.transparent,
  316. radarBorderData: const BorderSide(color: Colors.grey, width: 1),
  317. tickBorderData: const BorderSide(color: Colors.grey, width: 1),
  318. gridBorderData: const BorderSide(color: Colors.grey, width: 1),
  319. ticksTextStyle: const TextStyle(color: Colors.black, fontSize: 10),
  320. tickCount: 5,
  321. getTitle: (index, angle) {
  322. if (index >= 0 && index < labels.length) {
  323. return RadarChartTitle(text: labels[index], angle: angle);
  324. }
  325. return const RadarChartTitle(text: '');
  326. },
  327. titleTextStyle: const TextStyle(
  328. color: Colors.black,
  329. fontSize: 12,
  330. fontWeight: FontWeight.bold,
  331. ),
  332. titlePositionPercentageOffset: 0.15,
  333. ),
  334. swapAnimationDuration: const Duration(milliseconds: 500),
  335. swapAnimationCurve: Curves.easeInOut,
  336. );
  337. }
  338. }