histogram.dart 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  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 = 'With[{a=$a,d=$d,f=$f}, '
  44. 'With[{mid=Table[a+i*d+d/2,{i,0,Length[f]-1}]}, Total[mid*f]]]';
  45. final variance = 'With[{a=$a,d=$d,f=$f}, '
  46. 'With[{mid=Table[a+i*d+d/2,{i,0,Length[f]-1}]}, '
  47. 'Total[(mid-Total[mid*f])^2*f]]]';
  48. final outputs = <ToolOutput>[
  49. ...await exactAndApprox(session, '均值', mean),
  50. ...await exactAndApprox(session, '方差', variance),
  51. ...await exactAndApprox(session, '中位数', _quantile(a, d, f, '1/2')),
  52. ];
  53. if (_pct.text.isNotEmpty) {
  54. outputs.addAll(await exactAndApprox(
  55. session,
  56. '${_pct.text} 百分位数',
  57. _quantile(a, d, f, _pct.text),
  58. ));
  59. }
  60. // 图表:横轴标签 [左, 右),左端点/右端点用精确值;柱高是频率密度 f/d,用数值。
  61. final lr = parseSymjaList((await session.compute(
  62. 'With[{a=$a,d=$d,f=$f}, Table[{a+i*d, a+(i+1)*d}, {i,0,Length[f]-1}]]',
  63. ))
  64. .text);
  65. final dens = parseSymjaList((await session.compute(
  66. 'With[{a=$a,d=$d,f=$f}, N[Table[f[[i+1]]/d, {i,0,Length[f]-1}], 8]]',
  67. ))
  68. .text)
  69. .map(asNumber)
  70. .whereType<double>()
  71. .toList(growable: false);
  72. if (lr.length == dens.length && dens.isNotEmpty) {
  73. outputs.add(ToolChart('直方图', _histChart(lr, dens)));
  74. }
  75. return outputs;
  76. },
  77. );
  78. /// 累计频率首次超过 p 的那一组内插。中位数把 p 取 1/2。
  79. String _quantile(String a, String d, String f, String p) =>
  80. 'With[{a=$a,d=$d,f=$f,p=$p}, '
  81. 'Module[{cum=Accumulate[f],k}, '
  82. 'k=First[FirstPosition[cum, q_ /; q>p]]; '
  83. 'a+(k-1)*d+(p-If[k==1,0,cum[[k-1]]])/f[[k]]*d]]';
  84. Widget _histChart(List<Object> lr, List<double> dens) {
  85. var maxD = 0.0;
  86. for (final v in dens) {
  87. if (v > maxD) maxD = v;
  88. }
  89. final labels = <String>[
  90. for (final item in lr)
  91. if (item is List && item.length == 2)
  92. '[${item[0]}, ${item[1]})'
  93. else
  94. '$item',
  95. ];
  96. return SizedBox(
  97. height: 400,
  98. child: BarChart(
  99. BarChartData(
  100. alignment: BarChartAlignment.spaceBetween,
  101. groupsSpace: 0,
  102. maxY: maxD == 0 ? 1.0 : maxD * 1.1,
  103. barTouchData: BarTouchData(enabled: false),
  104. borderData: FlBorderData(show: true),
  105. gridData: FlGridData(show: false),
  106. titlesData: FlTitlesData(
  107. bottomTitles: AxisTitles(
  108. sideTitles: SideTitles(
  109. showTitles: true,
  110. reservedSize: 40,
  111. getTitlesWidget: (value, meta) {
  112. final i = value.toInt();
  113. if (i < 0 || i >= labels.length) return const Text('');
  114. return Padding(
  115. padding: const EdgeInsets.only(top: 8),
  116. child: Text(
  117. labels[i],
  118. style: const TextStyle(
  119. fontSize: 10, fontWeight: FontWeight.bold),
  120. textAlign: TextAlign.center,
  121. ),
  122. );
  123. },
  124. ),
  125. ),
  126. leftTitles: AxisTitles(
  127. sideTitles: SideTitles(
  128. showTitles: true,
  129. reservedSize: 40,
  130. getTitlesWidget: (value, meta) =>
  131. Text(value.toStringAsFixed(2)),
  132. ),
  133. ),
  134. topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
  135. rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
  136. ),
  137. barGroups: [
  138. for (var i = 0; i < dens.length; i++)
  139. BarChartGroupData(
  140. x: i,
  141. barRods: [
  142. BarChartRodData(
  143. toY: dens[i],
  144. width: 16,
  145. color: Colors.blue,
  146. borderRadius: BorderRadius.zero,
  147. ),
  148. ],
  149. ),
  150. ],
  151. ),
  152. ),
  153. );
  154. }
  155. }