// tools/chi_square.dart import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'tool_kit.dart'; /// 卡方检验(2×2 列联表)。 /// /// K² = n(ad − bc)² / ((a+b)(c+d)(a+c)(b+d)),n = a+b+c+d /// /// 精确值给分数、近似值给小数,都从 Symja 拿。图表里的比例数据同样走 Symja /// (`N[...]`),Dart 只负责把数字喂给 fl_chart —— 图表不参与复制。 class ChiSquarePage extends StatefulWidget { const ChiSquarePage({super.key}); @override State createState() => _ChiSquarePageState(); } class _ChiSquarePageState extends State { // 2×2 列联表就照列联表的样子排:a b 一行、c d 一行 final _a = ToolField('a', hint: '第一行左'); final _b = ToolField('b', hint: '第一行右', rowWithPrevious: true); final _c = ToolField('c', hint: '第二行左'); final _d = ToolField('d', hint: '第二行右', rowWithPrevious: true); @override Widget build(BuildContext context) => ToolScaffold( title: '卡方检验', fields: [_a, _b, _c, _d], compute: (session) async { final a = _need(_a); final b = _need(_b); final c = _need(_c); final d = _need(_d); final bind = 'a=$a,b=$b,c=$c,d=$d'; final k2 = 'With[{$bind}, Module[{nn=a+b+c+d}, ' 'nn*(a*d-b*c)^2/((a+b)*(c+d)*(a+c)*(b+d))]]'; // 各组比例(给图表,不复制):组1 = a/(a+b)、b/(a+b),组2 = c/(c+d)、d/(c+d) final prop = 'With[{$bind}, N[{a/(a+b), b/(a+b), c/(c+d), d/(c+d)}]]'; final p = parseSymjaList((await session.compute(prop)).text); final n0 = p.isNotEmpty ? asNumber(p[0]) ?? 0.0 : 0.0; final n1 = p.length > 1 ? asNumber(p[1]) ?? 0.0 : 0.0; final n2 = p.length > 2 ? asNumber(p[2]) ?? 0.0 : 0.0; final n3 = p.length > 3 ? asNumber(p[3]) ?? 0.0 : 0.0; return [ ...await exactAndApprox(session, 'K²', k2), ToolChart('各组比例', _barChart(n0, n1, n2, n3)), ]; }, ); /// 等高堆积条形图:两组各按 类别A / 类别B 的比例堆积,纵轴百分比。 Widget _barChart(double a1, double b1, double a2, double b2) => Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 220, child: BarChart( BarChartData( alignment: BarChartAlignment.spaceAround, maxY: 1.0, barTouchData: BarTouchData(enabled: false), titlesData: FlTitlesData( show: true, bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) => Text(value.toInt() == 0 ? '组1' : '组2'), ), ), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 40, getTitlesWidget: (value, meta) => Text('${(value * 100).toInt()}%'), ), ), topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)), rightTitles: AxisTitles( sideTitles: SideTitles(showTitles: false), ), ), gridData: FlGridData( show: true, drawVerticalLine: false, getDrawingHorizontalLine: (value) => FlLine( color: Colors.grey.withValues(alpha: 0.3), strokeWidth: 1, ), ), borderData: FlBorderData(show: false), barGroups: [ BarChartGroupData( x: 0, groupVertically: true, barsSpace: 4, barRods: [ BarChartRodData( fromY: 0, toY: a1, color: Colors.blue, width: 30, ), BarChartRodData( fromY: a1, toY: a1 + b1, color: Colors.red, width: 30, ), ], ), BarChartGroupData( x: 1, groupVertically: true, barsSpace: 4, barRods: [ BarChartRodData( fromY: 0, toY: a2, color: Colors.blue, width: 30, ), BarChartRodData( fromY: a2, toY: a2 + b2, color: Colors.red, width: 30, ), ], ), ], ), ), ), const SizedBox(height: 10), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _legend(Colors.blue, '类别A'), const SizedBox(width: 20), _legend(Colors.red, '类别B'), ], ), ], ); Widget _legend(Color color, String text) => Row( mainAxisSize: MainAxisSize.min, children: [ Container(width: 16, height: 16, color: color), const SizedBox(width: 8), Text(text), ], ); /// 取一个标量输入框:空着或填了多个值都直接报中文错。 String _need(ToolField f) { if (f.count == 0) throw Exception('请填写「${f.label}」'); if (f.count > 1) throw Exception('「${f.label}」只能填一个数'); return f.text; } }