// tools/histogram.dart import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'tool_kit.dart'; /// 直方图统计。 /// /// 组中值 midᵢ = a + i·d + d/2(i 从 0 起) /// 均值 = Σ midᵢfᵢ /// 方差 = Σ (midᵢ-均值)²fᵢ /// 中位数 / 百分位数:累计频率首次超过目标 p 的那一组内插 /// /// 频率和、均值、方差、分位数全部交给 Symja 求精确值;图表要 double, /// 才另跑一份 `N[...]` 的数值版。 class HistogramPage extends StatefulWidget { const HistogramPage({super.key}); @override State createState() => _HistogramPageState(); } class _HistogramPageState extends State { final _a = ToolField('左端点 a', hint: '如 0'); final _d = ToolField('组距 d', hint: '如 1'); final _f = ToolField('各组频率(用空格分隔)', hint: '用空格分隔多个值,和为 1'); final _pct = ToolField('百分位数(可选)', hint: '留空则不算,如 3/4 或 0.75'); @override Widget build(BuildContext context) => ToolScaffold( title: '直方图统计', fields: [_a, _d, _f, _pct], compute: (session) async { final a = _a.text, d = _d.text; if (a.isEmpty || d.isEmpty) throw Exception('请填写左端点 a 和组距 d'); if (_f.count == 0) throw Exception('请至少填一个频率'); final f = _f.list; // 频率之和必须为 1:交给 Symja 求 Total,原文不是 1 就报错。 final sum = await session.compute('Total[$f]'); if (sum.text != '1') { throw Exception('各组频率之和必须为 1,当前为 ${sum.text}'); } // mid 在均值和方差两个式子里都要用,用嵌套 With 定义一次,避免重复一大串。 final mean = 'With[{a=$a,d=$d,f=$f}, ' 'With[{mid=Table[a+i*d+d/2,{i,0,Length[f]-1}]}, Total[mid*f]]]'; final variance = 'With[{a=$a,d=$d,f=$f}, ' 'With[{mid=Table[a+i*d+d/2,{i,0,Length[f]-1}]}, ' 'Total[(mid-Total[mid*f])^2*f]]]'; final outputs = [ ...await exactAndApprox(session, '均值', mean), ...await exactAndApprox(session, '方差', variance), ...await exactAndApprox(session, '中位数', _quantile(a, d, f, '1/2')), ]; if (_pct.text.isNotEmpty) { outputs.addAll(await exactAndApprox( session, '${_pct.text} 百分位数', _quantile(a, d, f, _pct.text), )); } // 图表:横轴标签 [左, 右),左端点/右端点用精确值;柱高是频率密度 f/d,用数值。 final lr = parseSymjaList((await session.compute( 'With[{a=$a,d=$d,f=$f}, Table[{a+i*d, a+(i+1)*d}, {i,0,Length[f]-1}]]', )) .text); final dens = parseSymjaList((await session.compute( 'With[{a=$a,d=$d,f=$f}, N[Table[f[[i+1]]/d, {i,0,Length[f]-1}], 8]]', )) .text) .map(asNumber) .whereType() .toList(growable: false); if (lr.length == dens.length && dens.isNotEmpty) { outputs.add(ToolChart('直方图', _histChart(lr, dens))); } return outputs; }, ); /// 累计频率首次超过 p 的那一组内插。中位数把 p 取 1/2。 String _quantile(String a, String d, String f, String p) => 'With[{a=$a,d=$d,f=$f,p=$p}, ' 'Module[{cum=Accumulate[f],k}, ' 'k=First[FirstPosition[cum, q_ /; q>p]]; ' 'a+(k-1)*d+(p-If[k==1,0,cum[[k-1]]])/f[[k]]*d]]'; Widget _histChart(List lr, List dens) { var maxD = 0.0; for (final v in dens) { if (v > maxD) maxD = v; } final labels = [ for (final item in lr) if (item is List && item.length == 2) '[${item[0]}, ${item[1]})' else '$item', ]; return SizedBox( height: 400, child: BarChart( BarChartData( alignment: BarChartAlignment.spaceBetween, groupsSpace: 0, maxY: maxD == 0 ? 1.0 : maxD * 1.1, barTouchData: BarTouchData(enabled: false), borderData: FlBorderData(show: true), gridData: FlGridData(show: false), titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, getTitlesWidget: (value, meta) { final i = value.toInt(); if (i < 0 || i >= labels.length) return const Text(''); return Padding( padding: const EdgeInsets.only(top: 8), child: Text( labels[i], style: const TextStyle( fontSize: 10, fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), ); }, ), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, getTitlesWidget: (value, meta) => Text(value.toStringAsFixed(2)), ), ), topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), ), barGroups: [ for (var i = 0; i < dens.length; i++) BarChartGroupData( x: i, barRods: [ BarChartRodData( toY: dens[i], width: 16, color: Colors.blue, borderRadius: BorderRadius.zero, ), ], ), ], ), ), ); } }