histogram.dart 5.9 KB

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