// tools/binomial_distribution.dart import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'tool_kit.dart'; /// 二项分布 B(n, p)。 /// /// 期望 EX = n·p /// 方差 DX = n·p·(1-p) /// P(X = i) = C(n,i)·p^i·(1-p)^(n-i),i = 0…n /// /// 算式全部交给 Symja:p 允许写 `1/3` 这样的分数,精确概率就原样给分数。 /// 只有画图要 double,才另跑一份数值版表达式(绘图是唯一允许在 Dart 侧碰数的地方)。 class BinomialDistributionPage extends StatefulWidget { const BinomialDistributionPage({super.key}); @override State createState() => _BinomialDistributionPageState(); } class _BinomialDistributionPageState extends State { final _n = ToolField('n (试验次数)', hint: '整数,如 4'); final _p = ToolField('p (成功概率)', hint: '分数或小数,如 1/3'); @override Widget build(BuildContext context) => ToolScaffold( title: '二项分布', fields: [_n, _p], compute: (session) async { // n 只用来数个数、定循环范围,取整数是记账不是算数; // p 原样交给 Symja 解析(1/3 这种分数不能被 Dart 折成小数)。 final n = int.tryParse(_n.text); if (n == null || n < 0) { throw Exception('试验次数 n 必须是非负整数,现在是「${_n.text}」'); } final p = _p.text; if (p.isEmpty) throw Exception('请填写成功概率 p'); final outputs = [ ...await exactAndApprox(session, '期望 EX', 'With[{n=$n,p=$p}, n*p]'), ...await exactAndApprox( session, '方差 DX', 'With[{n=$n,p=$p}, n*p*(1-p)]', ), ]; // 每个 i 的概率:整列一次算出来,再拆成一条一条,这样每条都能单独复制。 // 精确列走 Rationalize(p 填小数时也能给成分数),数值列给图表用。 final exact = await session.compute( 'Rationalize[With[{n=$n,p=$p}, ' 'Table[Binomial[n,i]*p^i*(1-p)^(n-i), {i,0,n}]]]', ); final numeric = await session.compute( 'With[{n=$n,p=$p}, N[Table[Binomial[n,i]*p^i*(1-p)^(n-i), {i,0,n}], 8]]', ); final probs = parseSymjaList(exact.text); final nums = parseSymjaList(numeric.text); for (var i = 0; i < probs.length; i++) { outputs.add( ToolText( 'P(X = $i)', withApprox('${probs[i]}', i < nums.length ? '${nums[i]}' : ''), ), ); } final values = nums .map(asNumber) .whereType() .toList(growable: false); if (values.length == probs.length) { outputs.add(ToolChart('概率分布', _barChart(values))); } return outputs; }, ); /// 柱状图:最高的那一段(可能并排几个)涂红,左边蓝、右边绿 —— 保留原来的配色规则。 Widget _barChart(List probs) { var maxProb = 0.0; for (final v in probs) { if (v > maxProb) maxProb = v; } var firstMax = -1, lastMax = -1; for (var i = 0; i < probs.length; i++) { if (probs[i] == maxProb) { if (firstMax < 0) firstMax = i; lastMax = i; } } Color colorAt(int i) { if (i >= firstMax && i <= lastMax) return Colors.red; return i < firstMax ? Colors.blue.shade300 : Colors.green.shade300; } return SizedBox( height: 300, child: BarChart( BarChartData( barTouchData: BarTouchData(enabled: false), alignment: BarChartAlignment.spaceAround, maxY: maxProb == 0 ? 1.0 : maxProb * 1.1, borderData: FlBorderData(show: true), gridData: FlGridData(show: true), titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 30, getTitlesWidget: (value, meta) => Padding( padding: const EdgeInsets.only(top: 8), child: Text( '${value.toInt()}', style: const TextStyle(fontSize: 10), ), ), ), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, getTitlesWidget: (value, meta) => value % 0.1 == 0 ? Text( value.toStringAsFixed(1), style: const TextStyle(fontSize: 10), ) : const Text(''), ), ), topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), ), barGroups: [ for (var i = 0; i < probs.length; i++) BarChartGroupData( x: i, barRods: [ BarChartRodData( toY: probs[i], color: colorAt(i), width: 16, borderRadius: BorderRadius.circular(4), ), ], ), ], ), ), ); } }