histogram.dart 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. // tools/histogram.dart
  2. import 'package:fl_chart/fl_chart.dart';
  3. import 'package:flutter/material.dart';
  4. import 'tool_kit.dart';
  5. /// 直方图统计。
  6. ///
  7. /// 组中值 midᵢ = a + i·d + d/2(i 从 0 起)
  8. /// 均值 = Σ midᵢfᵢ
  9. /// 方差 = Σ (midᵢ-均值)²fᵢ
  10. /// 中位数 / 百分位数:累计频率首次超过目标 p 的那一组内插
  11. ///
  12. /// 频率和、均值、方差、分位数全部交给 Symja 求精确值;图表要 double,
  13. /// 才另跑一份 `N[...]` 的数值版。
  14. class HistogramPage extends StatefulWidget {
  15. const HistogramPage({super.key});
  16. @override
  17. State<HistogramPage> createState() => _HistogramPageState();
  18. }
  19. class _HistogramPageState extends State<HistogramPage> {
  20. final _a = ToolField('左端点 a', hint: '如 0');
  21. final _d = ToolField('组距 d', hint: '如 1');
  22. final _f = ToolField('各组频率(用空格分隔)', hint: '用空格分隔多个值,和为 1');
  23. final _pct = ToolField('百分位数(可选)', hint: '留空则不算,如 3/4 或 0.75');
  24. @override
  25. Widget build(BuildContext context) => ToolScaffold(
  26. title: '直方图统计',
  27. fields: [_a, _d, _f, _pct],
  28. compute: (session) async {
  29. final a = _a.text, d = _d.text;
  30. if (a.isEmpty || d.isEmpty) throw Exception('请填写左端点 a 和组距 d');
  31. if (_f.count == 0) throw Exception('请至少填一个频率');
  32. final f = _f.list;
  33. // 频率之和必须为 1:交给 Symja 求 Total,再让 Symja 判等。
  34. //
  35. // 判等也要走 Symja:频率填小数时 `Total` 回的是 `1.0`,拿 Dart 的
  36. // 字符串去比 `1` 会把合法输入判成错的(用户实测反馈过)。
  37. final ok = await session.compute('Total[$f] == 1');
  38. if (ok.text != 'True') {
  39. final sum = await session.compute('Total[$f]');
  40. throw Exception('各组频率之和必须为 1,当前为 ${sum.text}');
  41. }
  42. // mid 在均值和方差两个式子里都要用,用嵌套 With 定义一次,避免重复一大串。
  43. final mean =
  44. 'With[{a=$a,d=$d,f=$f}, '
  45. 'With[{mid=Table[a+i*d+d/2,{i,0,Length[f]-1}]}, Total[mid*f]]]';
  46. final variance =
  47. 'With[{a=$a,d=$d,f=$f}, '
  48. 'With[{mid=Table[a+i*d+d/2,{i,0,Length[f]-1}]}, '
  49. 'Total[(mid-Total[mid*f])^2*f]]]';
  50. final outputs = <ToolOutput>[
  51. ...await exactAndApprox(session, '均值', mean),
  52. ...await exactAndApprox(session, '方差', variance),
  53. ...await exactAndApprox(session, '中位数', _quantile(a, d, f, '1/2')),
  54. ];
  55. if (_pct.text.isNotEmpty) {
  56. outputs.addAll(
  57. await exactAndApprox(
  58. session,
  59. '${_pct.text} 百分位数',
  60. _quantile(a, d, f, _pct.text),
  61. ),
  62. );
  63. }
  64. // 图表:横轴标签 [左, 右),左端点/右端点用精确值;柱高是频率密度 f/d,用数值。
  65. final lr = parseSymjaList(
  66. (await session.compute(
  67. 'With[{a=$a,d=$d,f=$f}, Table[{a+i*d, a+(i+1)*d}, {i,0,Length[f]-1}]]',
  68. )).text,
  69. );
  70. final dens = parseSymjaList(
  71. (await session.compute(
  72. 'With[{a=$a,d=$d,f=$f}, N[Table[f[[i+1]]/d, {i,0,Length[f]-1}], 8]]',
  73. )).text,
  74. ).map(asNumber).whereType<double>().toList(growable: false);
  75. if (lr.length == dens.length && dens.isNotEmpty) {
  76. outputs.add(ToolChart('直方图', _histChart(lr, dens)));
  77. }
  78. return outputs;
  79. },
  80. );
  81. /// 累计频率首次超过 p 的那一组内插。中位数把 p 取 1/2。
  82. String _quantile(String a, String d, String f, String p) =>
  83. 'With[{a=$a,d=$d,f=$f,p=$p}, '
  84. 'Module[{cum=Accumulate[f],k}, '
  85. 'k=First[FirstPosition[cum, q_ /; q>p]]; '
  86. 'a+(k-1)*d+(p-If[k==1,0,cum[[k-1]]])/f[[k]]*d]]';
  87. Widget _histChart(List<Object> lr, List<double> dens) {
  88. var maxD = 0.0;
  89. for (final v in dens) {
  90. if (v > maxD) maxD = v;
  91. }
  92. final labels = <String>[
  93. for (final item in lr)
  94. if (item is List && item.length == 2)
  95. '[${item[0]}, ${item[1]})'
  96. else
  97. '$item',
  98. ];
  99. return SizedBox(
  100. height: 400,
  101. child: BarChart(
  102. BarChartData(
  103. alignment: BarChartAlignment.spaceBetween,
  104. groupsSpace: 0,
  105. maxY: maxD == 0 ? 1.0 : maxD * 1.1,
  106. barTouchData: BarTouchData(enabled: false),
  107. borderData: FlBorderData(show: true),
  108. gridData: FlGridData(show: false),
  109. titlesData: FlTitlesData(
  110. bottomTitles: AxisTitles(
  111. sideTitles: SideTitles(
  112. showTitles: true,
  113. reservedSize: 40,
  114. getTitlesWidget: (value, meta) {
  115. final i = value.toInt();
  116. if (i < 0 || i >= labels.length) return const Text('');
  117. return Padding(
  118. padding: const EdgeInsets.only(top: 8),
  119. child: Text(
  120. labels[i],
  121. style: const TextStyle(
  122. fontSize: 10,
  123. fontWeight: FontWeight.bold,
  124. ),
  125. textAlign: TextAlign.center,
  126. ),
  127. );
  128. },
  129. ),
  130. ),
  131. leftTitles: AxisTitles(
  132. sideTitles: SideTitles(
  133. showTitles: true,
  134. reservedSize: 40,
  135. getTitlesWidget: (value, meta) =>
  136. Text(value.toStringAsFixed(2)),
  137. ),
  138. ),
  139. topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
  140. rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
  141. ),
  142. barGroups: [
  143. for (var i = 0; i < dens.length; i++)
  144. BarChartGroupData(
  145. x: i,
  146. barRods: [
  147. BarChartRodData(
  148. toY: dens[i],
  149. width: 16,
  150. color: Colors.blue,
  151. borderRadius: BorderRadius.zero,
  152. ),
  153. ],
  154. ),
  155. ],
  156. ),
  157. ),
  158. );
  159. }
  160. }