statistical_charts.dart 11 KB

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