Jelajahi Sumber

工具页细节:行内并排的输入框、结果公式渲染、滑动条自动延展、若干修错

- 错位相减法直接给出渲染后的 Sₙ = (A·n + B)·qⁿ − B(值仍是 Symja 算的,
  Dart 只把数字摆进公式、分数写成 \frac)。**修了一处符号错**:尾巴那项是
  −B,之前跟括号里的 A·n + B 写成了一样,B = 2 时会显示成 `+ 2` 整个公式就错了
  —— 公式拼装抽成 sFormula() 并补了直接测它的用例
- 输入框可以并排:ToolField.rowWithPrevious。卷积(取值/概率一行)、卡方检验
  (a b / c d 的列联表)、对称变换与圆的方程(点的 x y 一行)、法向量(x y z 一行)
- 直方图:频率填小数时 Total 回的是 `1.0`,原来拿 Dart 字符串比 `1` 会把合法
  输入判成错的。判等也交给 Symja(`Total[f] == 1`)。卷积的概率和同样处理
- 函数图像:滑动条拉出采样区间后曲线就断了,现在视野超出采样范围会自动
  重新采样(CurvePlot 新增 onViewChanged,只报信,谁采的样谁负责延展)
- 分组分配、n 边形染色去掉说明文字,注册表位置挪到统计图表后面

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 3 minggu lalu
induk
melakukan
4b38cb7ed0

+ 23 - 8
lib/curve_plot.dart

@@ -32,10 +32,18 @@ class CurvePlot extends StatefulWidget {
     this.yMax,
     this.showControls = true,
     this.equalAspect = false,
+    this.onViewChanged,
   });
 
   final List<CurveSeries> series;
 
+  /// 横向窗口变了就报一声,让调用方有机会**重新采样**。
+  ///
+  /// 采样区间是有限的(函数图像只有 [-10, 10]),滑动条一拉出去曲线就断了 ——
+  /// 用户实测反馈"调整滑动条后图像不会自动延展"。谁采的样谁负责延展,
+  /// 所以这里只报信,不自己采。
+  final void Function(double x0, double x1)? onViewChanged;
+
   /// 纵轴的**初始**窗口(不给就按数据自动定界)。之后由滑动条接管。
   final double? yMin;
   final double? yMax;
@@ -180,14 +188,21 @@ class _CurvePlotState extends State<CurvePlot> {
 
     return Column(
       children: [
-        _slider('横向', xLo, xHi, x0, x1, (v) => setState(() {
-              _x0 = v.start;
-              _x1 = v.end;
-            })),
-        _slider('纵向', yLo, yHi, y0, y1, (v) => setState(() {
-              _y0 = v.start;
-              _y1 = v.end;
-            })),
+        _slider('横向', xLo, xHi, x0, x1, (v) {
+          setState(() {
+            _x0 = v.start;
+            _x1 = v.end;
+          });
+          widget.onViewChanged?.call(v.start, v.end);
+        }),
+        _slider('纵向', yLo, yHi, y0, y1, (v) {
+          setState(() {
+            _y0 = v.start;
+            _y1 = v.end;
+          });
+          // 纵向变化不影响 x 方向要不要补采样,但一起报出去省得调用方还要判断
+          widget.onViewChanged?.call(x0, x1);
+        }),
         Align(
           alignment: Alignment.centerRight,
           child: TextButton.icon(

+ 12 - 12
lib/plugins.dart

@@ -115,6 +115,18 @@ final List<ToolPlugin> kTools = [
     keywords: ['chart', 'tjth', '图表'],
     build: () => StatisticalChartsPage(),
   ),
+  ToolPlugin(
+    name: '分组分配',
+    icon: Icons.groups_outlined,
+    keywords: ['stirling', 'fzfp', '分组', '组合'],
+    build: () => GroupAllocationPage(),
+  ),
+  ToolPlugin(
+    name: 'n 边形染色',
+    icon: Icons.palette_outlined,
+    keywords: ['coloring', 'ranse', '染色', '图论'],
+    build: () => CycleColoringPage(),
+  ),
   ToolPlugin(
     name: '对称变换',
     icon: Icons.swap_horiz,
@@ -151,16 +163,4 @@ final List<ToolPlugin> kTools = [
     keywords: ['parametric', 'csfc', '绘图'],
     build: () => ParametricPlotterPage(),
   ),
-  ToolPlugin(
-    name: '分组分配',
-    icon: Icons.groups_outlined,
-    keywords: ['stirling', 'fzfp', '分组', '组合'],
-    build: () => GroupAllocationPage(),
-  ),
-  ToolPlugin(
-    name: 'n 边形染色',
-    icon: Icons.palette_outlined,
-    keywords: ['coloring', 'ranse', '染色', '图论'],
-    build: () => CycleColoringPage(),
-  ),
 ];

+ 120 - 121
lib/tools/chi_square.dart

@@ -18,149 +18,148 @@ class ChiSquarePage extends StatefulWidget {
 }
 
 class _ChiSquarePageState extends State<ChiSquarePage> {
+  // 2×2 列联表就照列联表的样子排:a b 一行、c d 一行
   final _a = ToolField('a', hint: '第一行左');
-  final _b = ToolField('b', hint: '第一行右');
+  final _b = ToolField('b', hint: '第一行右', rowWithPrevious: true);
   final _c = ToolField('c', hint: '第二行左');
-  final _d = ToolField('d', hint: '第二行右');
+  final _d = ToolField('d', hint: '第二行右', rowWithPrevious: true);
 
   @override
   Widget build(BuildContext context) => ToolScaffold(
-        title: '卡方检验',
-        fields: [_a, _b, _c, _d],
-        intro: const Text('请输入 2×2 列联表数据:'),
-        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';
+    title: '卡方检验',
+    fields: [_a, _b, _c, _d],
+    intro: const Text('请输入 2×2 列联表数据:'),
+    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))]]';
+      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;
+      // 各组比例(给图表,不复制):组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)),
-          ];
-        },
-      );
+      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),
-                  ),
+    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'),
                 ),
-                gridData: FlGridData(
-                  show: true,
-                  drawVerticalLine: false,
-                  getDrawingHorizontalLine: (value) => FlLine(
-                    color: Colors.grey.withValues(alpha: 0.3),
-                    strokeWidth: 1,
-                  ),
+              ),
+              leftTitles: AxisTitles(
+                sideTitles: SideTitles(
+                  showTitles: true,
+                  reservedSize: 40,
+                  getTitlesWidget: (value, meta) =>
+                      Text('${(value * 100).toInt()}%'),
                 ),
-                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,
-                      ),
-                    ],
+              ),
+              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,
                   ),
-                  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,
-                      ),
-                    ],
+                  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'),
             ],
           ),
+        ),
+      ),
+      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),
-        ],
-      );
+    mainAxisSize: MainAxisSize.min,
+    children: [
+      Container(width: 16, height: 16, color: color),
+      const SizedBox(width: 8),
+      Text(text),
+    ],
+  );
 
   /// 取一个标量输入框:空着或填了多个值都直接报中文错。
   String _need(ToolField f) {

+ 46 - 40
lib/tools/circle_equation.dart

@@ -17,54 +17,60 @@ class CircleEquationPage extends StatefulWidget {
 
 class _CircleEquationPageState extends State<CircleEquationPage> {
   final _x1 = ToolField('点1 x', hint: '分数如 1/2,或小数');
-  final _y1 = ToolField('点1 y', hint: '分数如 1/2,或小数');
+  // 点的横纵坐标是一对,并排一行
+  final _y1 = ToolField('点1 y', hint: '分数如 1/2,或小数', rowWithPrevious: true);
   final _x2 = ToolField('点2 x', hint: '分数如 1/2,或小数');
-  final _y2 = ToolField('点2 y', hint: '分数如 1/2,或小数');
+  // 点的横纵坐标是一对,并排一行
+  final _y2 = ToolField('点2 y', hint: '分数如 1/2,或小数', rowWithPrevious: true);
   final _x3 = ToolField('点3 x', hint: '分数如 1/2,或小数');
-  final _y3 = ToolField('点3 y', hint: '分数如 1/2,或小数');
+  // 点的横纵坐标是一对,并排一行
+  final _y3 = ToolField('点3 y', hint: '分数如 1/2,或小数', rowWithPrevious: true);
 
   @override
   Widget build(BuildContext context) => ToolScaffold(
-        title: '圆的方程',
-        fields: [_x1, _y1, _x2, _y2, _x3, _y3],
-        intro: const Text('请输入三个点的坐标:'),
-        compute: (session) async {
-          final bind = 'x1=${_need(_x1)},y1=${_need(_y1)},'
-              'x2=${_need(_x2)},y2=${_need(_y2)},'
-              'x3=${_need(_x3)},y3=${_need(_y3)}';
-          final eqs = '{x1^2+y1^2+dd*x1+ee*y1+ff==0, '
-              'x2^2+y2^2+dd*x2+ee*y2+ff==0, '
-              'x3^2+y3^2+dd*x3+ee*y3+ff==0}';
-          const noCircle = '"三点共线(没有外接圆)"';
+    title: '圆的方程',
+    fields: [_x1, _y1, _x2, _y2, _x3, _y3],
+    intro: const Text('请输入三个点的坐标:'),
+    compute: (session) async {
+      final bind =
+          'x1=${_need(_x1)},y1=${_need(_y1)},'
+          'x2=${_need(_x2)},y2=${_need(_y2)},'
+          'x3=${_need(_x3)},y3=${_need(_y3)}';
+      final eqs =
+          '{x1^2+y1^2+dd*x1+ee*y1+ff==0, '
+          'x2^2+y2^2+dd*x2+ee*y2+ff==0, '
+          'x3^2+y3^2+dd*x3+ee*y3+ff==0}';
+      const noCircle = '"三点共线(没有外接圆)"';
 
-          // 三个系数
-          final defExpr = 'With[{$bind}, Module[{s}, s=Solve[$eqs,{dd,ee,ff}]; '
-              'If[s==={}, $noCircle, {dd,ee,ff} /. First[s]]]]';
-          final defs = parseSymjaList((await session.compute(defExpr)).text);
-          if (defs.length != 3) {
-            // 共线:Symja 返回的就是那句中文,直接显示。
-            return [ToolText('结果', '${defs.isEmpty ? '' : defs.first}')];
-          }
+      // 三个系数
+      final defExpr =
+          'With[{$bind}, Module[{s}, s=Solve[$eqs,{dd,ee,ff}]; '
+          'If[s==={}, $noCircle, {dd,ee,ff} /. First[s]]]]';
+      final defs = parseSymjaList((await session.compute(defExpr)).text);
+      if (defs.length != 3) {
+        // 共线:Symja 返回的就是那句中文,直接显示。
+        return [ToolText('结果', '${defs.isEmpty ? '' : defs.first}')];
+      }
 
-          // 圆心 (-dd/2, -ee/2) 与半径平方 (dd²+ee²-4ff)/4
-          final centerExpr =
-              'With[{$bind}, Module[{s}, s=Solve[$eqs,{dd,ee,ff}]; '
-              'If[s==={}, $noCircle, '
-              '{-dd/2, -ee/2, (dd^2+ee^2-4*ff)/4} /. First[s]]]]';
-          final center = parseSymjaList((await session.compute(centerExpr)).text);
+      // 圆心 (-dd/2, -ee/2) 与半径平方 (dd²+ee²-4ff)/4
+      final centerExpr =
+          'With[{$bind}, Module[{s}, s=Solve[$eqs,{dd,ee,ff}]; '
+          'If[s==={}, $noCircle, '
+          '{-dd/2, -ee/2, (dd^2+ee^2-4*ff)/4} /. First[s]]]]';
+      final center = parseSymjaList((await session.compute(centerExpr)).text);
 
-          final out = <ToolOutput>[
-            ToolText('D', '${defs[0]}'),
-            ToolText('E', '${defs[1]}'),
-            ToolText('F', '${defs[2]}'),
-          ];
-          if (center.length == 3) {
-            out.add(ToolText('圆心', '(${center[0]}, ${center[1]})'));
-            out.add(ToolText('半径平方', '${center[2]}'));
-          }
-          return out;
-        },
-      );
+      final out = <ToolOutput>[
+        ToolText('D', '${defs[0]}'),
+        ToolText('E', '${defs[1]}'),
+        ToolText('F', '${defs[2]}'),
+      ];
+      if (center.length == 3) {
+        out.add(ToolText('圆心', '(${center[0]}, ${center[1]})'));
+        out.add(ToolText('半径平方', '${center[2]}'));
+      }
+      return out;
+    },
+  );
 
   /// 取一个标量输入框:空着或填了多个值都直接报中文错。
   String _need(ToolField f) {

+ 112 - 94
lib/tools/convolution_calculator.dart

@@ -1,6 +1,7 @@
 // tools/convolution_calculator.dart
 import 'package:flutter/material.dart';
 
+import '../symja.dart';
 import 'tool_kit.dart';
 
 /// 卷积(分布列相加):把 X、Y 两个分布列逐对组合,得到 X + Y 的分布列。
@@ -19,8 +20,13 @@ class ConvolutionCalculatorPage extends StatefulWidget {
 /// 一行输入:一个取值 + 一个概率。
 class _Row {
   _Row(String name, int index, String v, String p)
-      : value = ToolField('$name 取值 $index', hint: '如 0'),
-        prob = ToolField('$name 概率 $index', hint: '如 1/2') {
+    : value = ToolField('$name 取值 $index', hint: '如 0'),
+      // 取值和概率并排一行 —— 它们本来就是一行的两个格子
+      prob = ToolField(
+        '$name 概率 $index',
+        hint: '如 1/2',
+        rowWithPrevious: true,
+      ) {
     value.controller.text = v;
     prob.controller.text = p;
   }
@@ -29,8 +35,7 @@ class _Row {
   final ToolField prob;
 }
 
-class _ConvolutionCalculatorPageState
-    extends State<ConvolutionCalculatorPage> {
+class _ConvolutionCalculatorPageState extends State<ConvolutionCalculatorPage> {
   // 只允许在末尾增删,行号(标签里的 1、2…)才不会错位。
   final List<_Row> _x = [];
   final List<_Row> _y = [];
@@ -55,10 +60,22 @@ class _ConvolutionCalculatorPageState
     setState(() => rows.removeLast());
   }
 
+  /// 校验某一列概率之和为 1。判等走 Symja(见调用处的注释)。
+  Future<void> _requireSumOne(
+    SymjaSession session,
+    String name,
+    String probs,
+  ) async {
+    final ok = await session.compute('Total[$probs] == 1');
+    if (ok.text == 'True') return;
+    final sum = await session.compute('Total[$probs]');
+    throw Exception('$name 的概率之和必须为 1,当前为 ${sum.text}');
+  }
+
   List<ToolField> get _fields => [
-        for (final r in _x) ...[r.value, r.prob],
-        for (final r in _y) ...[r.value, r.prob],
-      ];
+    for (final r in _x) ...[r.value, r.prob],
+    for (final r in _y) ...[r.value, r.prob],
+  ];
 
   String _pairs(List<_Row> rows) =>
       '{${rows.map((r) => '{${r.value.text}, ${r.prob.text}}').join(', ')}}';
@@ -76,96 +93,97 @@ class _ConvolutionCalculatorPageState
 
   @override
   Widget build(BuildContext context) => ToolScaffold(
-        title: '卷积分布列',
-        fields: _fields,
-        intro: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            _sectionTitle('X 的分布列', 'X', _x),
-            _sectionTitle('Y 的分布列', 'Y', _y),
-          ],
-        ),
-        compute: (session) async {
-          _validate('X', _x);
-          _validate('Y', _y);
-
-          final xPairs = _pairs(_x);
-          final yPairs = _pairs(_y);
-
-          // 概率之和必须为 1:交给 Symja 求 Total。
-          final xSum = await session.compute('Total[${_probs(_x)}]');
-          if (xSum.text != '1') {
-            throw Exception('X 的概率之和必须为 1,当前为 ${xSum.text}');
-          }
-          final ySum = await session.compute('Total[${_probs(_y)}]');
-          if (ySum.text != '1') {
-            throw Exception('Y 的概率之和必须为 1,当前为 ${ySum.text}');
-          }
-
-          // 卷积:逐对组合,先不合并(Symja 给的是一对对 {取值, 概率})。
-          final conv = await session.compute(
-            'With[{x=$xPairs, y=$yPairs}, '
-            'Flatten[Table[{x[[i,1]]+y[[j,1]], x[[i,2]]*y[[j,2]]}, '
-            '{i,1,Length[x]}, {j,1,Length[y]}], 1]]',
-          );
-          final pairs = parseSymjaList(conv.text);
-
-          // 按取值原文分组(纯记账)。每组的概率仍交给 Symja 相加,保证是精确分数。
-          final order = <String>[];
-          final groups = <String, List<String>>{};
-          for (final item in pairs) {
-            if (item is! List || item.length < 2) continue;
-            final v = '${item[0]}';
-            final p = '${item[1]}';
-            groups.putIfAbsent(v, () {
+    title: '卷积分布列',
+    fields: _fields,
+    intro: Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        _sectionTitle('X 的分布列', 'X', _x),
+        _sectionTitle('Y 的分布列', 'Y', _y),
+      ],
+    ),
+    compute: (session) async {
+      _validate('X', _x);
+      _validate('Y', _y);
+
+      final xPairs = _pairs(_x);
+      final yPairs = _pairs(_y);
+
+      // 概率之和必须为 1:求和和**判等**都交给 Symja —— 概率填小数时
+      // `Total` 回的是 `0.9999999999999999` / `1.0` 这种,拿 Dart 字符串
+      // 去比 `1` 会把合法输入判成错的。
+      await _requireSumOne(session, 'X', _probs(_x));
+      await _requireSumOne(session, 'Y', _probs(_y));
+
+      // 卷积:逐对组合,先不合并(Symja 给的是一对对 {取值, 概率})。
+      final conv = await session.compute(
+        'With[{x=$xPairs, y=$yPairs}, '
+        'Flatten[Table[{x[[i,1]]+y[[j,1]], x[[i,2]]*y[[j,2]]}, '
+        '{i,1,Length[x]}, {j,1,Length[y]}], 1]]',
+      );
+      final pairs = parseSymjaList(conv.text);
+
+      // 按取值原文分组(纯记账)。每组的概率仍交给 Symja 相加,保证是精确分数。
+      final order = <String>[];
+      final groups = <String, List<String>>{};
+      for (final item in pairs) {
+        if (item is! List || item.length < 2) continue;
+        final v = '${item[0]}';
+        final p = '${item[1]}';
+        groups
+            .putIfAbsent(v, () {
               order.add(v);
               return <String>[];
-            }).add(p);
-          }
-
-          // 排序也交给 Symja:Sort 按数值大小排,比 Dart 的字符串排序可靠
-          // (字符串排序会把 10 排在 2 前面)。取值不多,单独问一次开销很小。
-          final sorted = order.isEmpty
-              ? const <Object>[]
-              : parseSymjaList(
-                  (await session.compute('Sort[{${order.join(', ')}}]')).text);
-
-          final outputs = <ToolOutput>[];
-          final groupSums = <String>[];
-          for (final v in sorted) {
-            final key = '$v';
-            final ps = groups[key];
-            if (ps == null) continue;
-            final total = await session.compute('Total[{${ps.join(', ')}}]');
-            groupSums.add(total.text);
-            outputs.add(ToolText('X + Y = $key', total.text));
-          }
-
-          final grand = await session.compute('Total[{${groupSums.join(', ')}}]');
-          outputs.add(ToolText('概率总和', grand.text));
-          return outputs;
-        },
-      );
+            })
+            .add(p);
+      }
+
+      // 排序也交给 Symja:Sort 按数值大小排,比 Dart 的字符串排序可靠
+      // (字符串排序会把 10 排在 2 前面)。取值不多,单独问一次开销很小。
+      final sorted = order.isEmpty
+          ? const <Object>[]
+          : parseSymjaList(
+              (await session.compute('Sort[{${order.join(', ')}}]')).text,
+            );
+
+      final outputs = <ToolOutput>[];
+      final groupSums = <String>[];
+      for (final v in sorted) {
+        final key = '$v';
+        final ps = groups[key];
+        if (ps == null) continue;
+        final total = await session.compute('Total[{${ps.join(', ')}}]');
+        groupSums.add(total.text);
+        outputs.add(ToolText('X + Y = $key', total.text));
+      }
+
+      final grand = await session.compute('Total[{${groupSums.join(', ')}}]');
+      outputs.add(ToolText('概率总和', grand.text));
+      return outputs;
+    },
+  );
 
   Widget _sectionTitle(String title, String name, List<_Row> rows) => Padding(
-        padding: const EdgeInsets.only(top: 8),
-        child: Row(
-          children: [
-            Expanded(
-              child: Text(title,
-                  style: const TextStyle(fontWeight: FontWeight.bold)),
-            ),
-            TextButton.icon(
-              onPressed: () => _removeRow(rows),
-              icon: const Icon(Icons.remove, size: 18),
-              label: const Text('删除一行'),
-            ),
-            TextButton.icon(
-              onPressed: () => _addRow(name, rows),
-              icon: const Icon(Icons.add, size: 18),
-              label: const Text('添加一行'),
-            ),
-          ],
+    padding: const EdgeInsets.only(top: 8),
+    child: Row(
+      children: [
+        Expanded(
+          child: Text(
+            title,
+            style: const TextStyle(fontWeight: FontWeight.bold),
+          ),
         ),
-      );
+        TextButton.icon(
+          onPressed: () => _removeRow(rows),
+          icon: const Icon(Icons.remove, size: 18),
+          label: const Text('删除一行'),
+        ),
+        TextButton.icon(
+          onPressed: () => _addRow(name, rows),
+          icon: const Icon(Icons.add, size: 18),
+          label: const Text('添加一行'),
+        ),
+      ],
+    ),
+  );
 }

+ 63 - 3
lib/tools/cuo_wei_xiang_jian.dart

@@ -45,9 +45,26 @@ class _CuoWeiXiangJianPageState extends State<CuoWeiXiangJianPage> {
           final bExpr =
               'With[{$bind}, (vb*vq^(vm+1)-(va*vq^(vm+1)/(vq-1)))/(vq-1)]';
 
-          return [
-            ...await exactAndApprox(session, 'A', aExpr),
-            ...await exactAndApprox(session, 'B', bExpr),
+          final aRes = await session.compute(aExpr);
+          final bRes = await session.compute(bExpr);
+          // q 也回炉一次:用户可能填 `0.5`,Symja 会规范成 `1/2`,渲染才好看
+          final qv = (await session.compute('With[{$bind}, vq]')).text;
+          final av = aRes.text;
+          final bv = bRes.text;
+
+          // 结果那条:Sₙ = (A·n + B)·qⁿ − B,把算出来的 A、B、q 代进去显示。
+          // **值全是 Symja 算的**,这里只负责把数字摆进公式、分数写成 \frac。
+          final s = sFormula(a: av, b: bv, q: qv);
+
+          final aApprox = await session.compute('N[$aExpr, 10]');
+          final bApprox = await session.compute('N[$bExpr, 10]');
+
+          return <ToolOutput>[
+            ToolText('结果 Sₙ = (A·n + B)·qⁿ − B', s.plain, tex: s.tex),
+            ToolText('A', av, tex: aRes.tex),
+            if (aApprox.text != av) ToolText('A(近似)', aApprox.text),
+            ToolText('B', bv, tex: bRes.tex),
+            if (bApprox.text != bv) ToolText('B(近似)', bApprox.text),
           ];
         },
       );
@@ -59,3 +76,46 @@ class _CuoWeiXiangJianPageState extends State<CuoWeiXiangJianPage> {
     return f.text;
   }
 }
+
+/// 拼出 Sₙ = (A·n + B)·qⁿ − B 的两种写法(给人看的纯文本 / 给渲染的 LaTeX)。
+///
+/// 抽成顶层函数是为了能直接测:**尾巴那项是 −B**,B 取负值时该写成 `+ |B|`,
+/// 括号里才是 `A·n + B`(用 B 自己的符号)。这里把两者写成一样过一次,
+/// 结果 B = 2 时尾巴显示成 `+ 2`,整个公式是错的。
+///
+/// [a]、[b]、[q] 都是 Symja 算出来的数值原文(`-4`、`1/2` …),这里只排版。
+@visibleForTesting
+({String plain, String tex}) sFormula({
+  required String a,
+  required String b,
+  required String q,
+}) =>
+    (
+      plain: 'S(n) = ($a·n ${_signed(b, _plain)})·$q^n '
+          '${_signed(_neg(b), _plain)}',
+      tex: 'S_{n} = (${_frac(a)}\\cdot n ${_signed(b, _frac)})\\cdot '
+          '{${_frac(q)}}^{n} ${_signed(_neg(b), _frac)}',
+    );
+
+/// `+ 4` / `- \frac{1}{2}`:把带符号的数接到前一项后面,
+/// 不要写成 `+ (-4)` 那种。
+String _signed(String v, String Function(String) fmt) {
+  final t = v.trim();
+  return t.startsWith('-') ? '- ${fmt(t.substring(1))}' : '+ ${fmt(t)}';
+}
+
+String _plain(String v) => v;
+
+/// 取相反数。值都是 Symja 给的数字文本,翻个符号就行。
+String _neg(String v) {
+  final t = v.trim();
+  return t.startsWith('-') ? t.substring(1) : '-$t';
+}
+
+/// 数值文本 → LaTeX:分数写成 `\frac`,其余原样。值都是 Symja 给的,这里只是排版。
+String _frac(String v) {
+  final t = v.trim();
+  final m = RegExp(r'^(-?)(\d+)/(\d+)$').firstMatch(t);
+  if (m == null) return t;
+  return '${m[1]}\\frac{${m[2]}}{${m[3]}}';
+}

+ 0 - 2
lib/tools/cycle_coloring.dart

@@ -21,8 +21,6 @@ class _CycleColoringPageState extends State<CycleColoringPage> {
   Widget build(BuildContext context) => ToolScaffold(
         title: 'n 边形染色',
         fields: [_n, _k],
-        intro: const Text('用 k 种颜色给 n 边形的顶点染色,相邻顶点不同色\n'
-            '方案数 = (k−1)ⁿ + (k−1)·(−1)ⁿ'),
         compute: (session) async {
           final n = int.tryParse(_n.text);
           final k = int.tryParse(_k.text);

+ 112 - 2
lib/tools/function_plotter.dart

@@ -4,6 +4,8 @@
 //
 // 采样部分(区间、点数、由 sample 拿点)照旧不变;变的只是外壳 —— 输入框交给
 // [ToolScaffold],绘制按钮就是外壳的「计算」,重置按钮也是外壳自带的。
+import 'dart:math' as math;
+
 import 'package:flutter/material.dart';
 
 import '../curve_plot.dart';
@@ -98,12 +100,14 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
         fields: _exprs,
         intro: _intro(),
         compute: (session) async {
+          final curves = <({String expr, Color color})>[];
           final series = <CurveSeries>[];
           final failed = <String>[];
           // 逐条采样:一条失败只记下它自己,别的照画。
           for (var i = 0; i < _exprs.length; i++) {
             final raw = _exprs[i].text;
             if (raw.isEmpty) continue;
+            final color = _curveColors[i % _curveColors.length];
             try {
               // 表达式原样交给 Symja 解析,谁解析谁负责。
               final s = await session.sample(
@@ -113,9 +117,10 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
                 to: _sampleTo,
                 count: _sampleCount,
               );
+              curves.add((expr: raw, color: color));
               series.add(CurveSeries(
                 label: raw,
-                color: _curveColors[i % _curveColors.length],
+                color: color,
                 // 非有限值(极点处的 ComplexInfinity 等)原样保留,
                 // CurvePlot 会把它们当成抬笔断点。
                 points: <Offset>[
@@ -135,7 +140,16 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
                 '函数图像',
                 // CurvePlot 内部用 Expanded,必须给一个确定高度,
                 // 否则放在外层 Column 里会因为高度无界而报错。
-                SizedBox(height: 360, child: CurvePlot(series: series)),
+                SizedBox(
+                  height: 360,
+                  child: _FunctionChart(
+                    session: session,
+                    curves: curves,
+                    initialSeries: series,
+                    initialFrom: _sampleFrom,
+                    initialTo: _sampleTo,
+                  ),
+                ),
               ),
             if (failed.isNotEmpty)
               ToolText('未能绘制的函数', failed.join('\n')),
@@ -143,3 +157,99 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
         },
       );
 }
+
+/// 会跟着横向滑动条**重新采样**的函数图像。
+///
+/// 采样区间原来是写死的 [-10, 10],滑动条一拉出去曲线就断在那儿了(用户实测
+/// 反馈"调整滑动条后图像不会自动延展")。这里接住 CurvePlot 报回来的窗口,
+/// 只要视野跑出已采样的范围就往外扩一圈、把函数重新采一遍 —— 视野里永远有曲线。
+class _FunctionChart extends StatefulWidget {
+  const _FunctionChart({
+    required this.session,
+    required this.curves,
+    required this.initialSeries,
+    required this.initialFrom,
+    required this.initialTo,
+  });
+
+  final SymjaSession session;
+
+  /// 要画的函数(表达式 + 颜色)。重新采样时按同一份清单再采一遍。
+  final List<({String expr, Color color})> curves;
+
+  /// 页面已经采好的第一批点,免得这里再采一次。
+  final List<CurveSeries> initialSeries;
+
+  final double initialFrom;
+  final double initialTo;
+
+  @override
+  State<_FunctionChart> createState() => _FunctionChartState();
+}
+
+class _FunctionChartState extends State<_FunctionChart> {
+  /// 每单位 x 采几个点。放大到一小段时也还画得平滑。
+  static const double _perUnit = 60;
+
+  late double _from = widget.initialFrom;
+  late double _to = widget.initialTo;
+  late List<CurveSeries> _series = widget.initialSeries;
+
+  bool _sampling = false;
+
+  /// 视野跑出已采样范围(两边各留 10% 余量)才动手,否则拖动过程中会一直重采。
+  void _onView(double x0, double x1) {
+    final span = _to - _from;
+    if (x0 >= _from + span * 0.1 && x1 <= _to - span * 0.1) return;
+    final from = math.min(_from, x0 - span * 0.15);
+    final to = math.max(_to, x1 + span * 0.15);
+    _resample(from, to);
+  }
+
+  Future<void> _resample(double from, double to) async {
+    if (_sampling) return; // 采样期间不排队:松开滑条后还会再报一次
+    _sampling = true;
+    try {
+      final count = ((to - from) * _perUnit).round().clamp(400, 4000);
+      final out = <CurveSeries>[];
+      for (final c in widget.curves) {
+        try {
+          final s = await widget.session.sample(
+            c.expr,
+            variable: 'x',
+            from: from,
+            to: to,
+            count: count,
+          );
+          out.add(CurveSeries(
+            label: c.expr,
+            color: c.color,
+            points: <Offset>[
+              for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]),
+            ],
+          ));
+        } on SymjaException {
+          // 拉远之后算不动了(比如跑到定义域外、或被取消):留着原来的曲线,
+          // 别把已经画出来的图清空。
+        }
+      }
+      if (!mounted) return;
+      // 全都失败就当这次没发生过 —— 至少不会把图变空
+      if (out.length == widget.curves.length) {
+        setState(() {
+          _from = from;
+          _to = to;
+          _series = out;
+        });
+      }
+    } finally {
+      _sampling = false;
+    }
+  }
+
+  @override
+  Widget build(BuildContext context) => CurvePlot(
+        series: _series,
+        onViewChanged: _onView,
+      );
+}

+ 0 - 2
lib/tools/group_allocation.dart

@@ -21,8 +21,6 @@ class _GroupAllocationPageState extends State<GroupAllocationPage> {
   Widget build(BuildContext context) => ToolScaffold(
         title: '分组分配',
         fields: [_n, _k],
-        intro: const Text('把 n 个人分成 k 个有区别的组(每组至少一人)\n'
-            '方案数 = k! · S(n, k),S 是第二类斯特林数'),
         compute: (session) async {
           final n = int.tryParse(_n.text);
           final k = int.tryParse(_k.text);

+ 7 - 3
lib/tools/histogram.dart

@@ -36,9 +36,13 @@ class _HistogramPageState extends State<HistogramPage> {
           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') {
+          // 频率之和必须为 1:交给 Symja 求 Total,再让 Symja 判等。
+          //
+          // 判等也要走 Symja:频率填小数时 `Total` 回的是 `1.0`,拿 Dart 的
+          // 字符串去比 `1` 会把合法输入判成错的(用户实测反馈过)。
+          final ok = await session.compute('Total[$f] == 1');
+          if (ok.text != 'True') {
+            final sum = await session.compute('Total[$f]');
             throw Exception('各组频率之和必须为 1,当前为 ${sum.text}');
           }
 

+ 3 - 2
lib/tools/symmetry.dart

@@ -23,7 +23,8 @@ class _SymmetryPageState extends State<SymmetryPage> {
   final Map<int, List<ToolField>> _fields = {
     0: [
       ToolField('点 x', hint: '分数如 1/2,或小数'),
-      ToolField('点 y', hint: '分数如 1/2,或小数'),
+      // 点的横纵坐标是一对,并排一行
+      ToolField('点 y', hint: '分数如 1/2,或小数', rowWithPrevious: true),
       ToolField('直线 A', hint: 'Ax + By + C = 0 的 A'),
       ToolField('直线 B', hint: 'Ax + By + C = 0 的 B'),
       ToolField('直线 C', hint: 'Ax + By + C = 0 的 C'),
@@ -33,7 +34,7 @@ class _SymmetryPageState extends State<SymmetryPage> {
       ToolField('直线 B', hint: 'Ax + By + C = 0 的 B'),
       ToolField('直线 C', hint: 'Ax + By + C = 0 的 C'),
       ToolField('点 x', hint: '分数如 1/2,或小数'),
-      ToolField('点 y', hint: '分数如 1/2,或小数'),
+      ToolField('点 y', hint: '分数如 1/2,或小数', rowWithPrevious: true),
     ],
     2: [
       ToolField('原直线 A', hint: 'Px + Qy + R = 0 的 P'),

+ 39 - 1
lib/tools/tool_kit.dart

@@ -107,6 +107,37 @@ class _ToolScaffoldState extends State<ToolScaffold> {
     });
   }
 
+  /// 把输入框排成一列,遇到标了 [ToolField.rowWithPrevious] 的就跟前一个并排。
+  List<Widget> _fieldRows(BuildContext context) {
+    final rows = <Widget>[];
+    var group = <ToolField>[];
+
+    void flush() {
+      if (group.isEmpty) return;
+      if (group.length == 1) {
+        rows.add(group.first.build(context));
+      } else {
+        rows.add(Row(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            for (var i = 0; i < group.length; i++) ...[
+              if (i > 0) const SizedBox(width: 10),
+              Expanded(child: group[i].build(context)),
+            ],
+          ],
+        ));
+      }
+      group = <ToolField>[];
+    }
+
+    for (final f in widget.fields) {
+      if (!f.rowWithPrevious) flush();
+      group.add(f);
+    }
+    flush();
+    return rows;
+  }
+
   /// 计算中:给个可见的反馈,复杂表达式可能要算好几秒。
   Widget _busyStrip(ColorScheme scheme) => Padding(
         padding: const EdgeInsets.symmetric(vertical: 10),
@@ -136,7 +167,7 @@ class _ToolScaffoldState extends State<ToolScaffold> {
             widget.intro!,
             const SizedBox(height: 12),
           ],
-          for (final f in widget.fields) f.build(context),
+          ..._fieldRows(context),
           const SizedBox(height: 12),
           Row(
             children: [
@@ -200,6 +231,7 @@ class ToolField {
       signed: true,
       decimal: true,
     ),
+    this.rowWithPrevious = false,
   })  : hint = hint ?? '输入分数如 1/2,或多个值用空格分隔',
         controller = TextEditingController();
 
@@ -208,6 +240,12 @@ class ToolField {
   final TextInputType keyboard;
   final TextEditingController controller;
 
+  /// 和**上一个**输入框并排放在同一行(宽度平分)。
+  ///
+  /// 成对出现的东西不该各占一行 —— 卷积的「取值 i / 概率 i」、
+  /// 卡方检验的 2×2 列联表都是这样。
+  final bool rowWithPrevious;
+
   String get text => controller.text.trim();
 
   /// 这一栏里填了几个值。用来做"三组数据个数要一样"这类检查 ——

+ 74 - 31
lib/tools/vector_normal.dart

@@ -19,15 +19,60 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
   // 9 个坐标框建在 State 里:主题一变页面重建时控制器不会跟着换掉、用户打的字会丢。
   // 顺序:A.x A.y A.z B.x B.y B.z C.x C.y C.z。
   final List<ToolField> _coords = [
-    ToolField('点 A 的 x', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
-    ToolField('点 A 的 y', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
-    ToolField('点 A 的 z', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
-    ToolField('点 B 的 x', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
-    ToolField('点 B 的 y', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
-    ToolField('点 B 的 z', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
-    ToolField('点 C 的 x', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
-    ToolField('点 C 的 y', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
-    ToolField('点 C 的 z', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
+    ToolField(
+      '点 A 的 x',
+      hint: '例如 2/3、Sqrt[2]、Pi',
+      keyboard: TextInputType.text,
+    ),
+    // 一个点的三个坐标并排一行
+    ToolField(
+      '点 A 的 y',
+      hint: '例如 2/3、Sqrt[2]、Pi',
+      keyboard: TextInputType.text,
+      rowWithPrevious: true,
+    ),
+    ToolField(
+      '点 A 的 z',
+      hint: '例如 2/3、Sqrt[2]、Pi',
+      keyboard: TextInputType.text,
+      rowWithPrevious: true,
+    ),
+    ToolField(
+      '点 B 的 x',
+      hint: '例如 2/3、Sqrt[2]、Pi',
+      keyboard: TextInputType.text,
+    ),
+    // 一个点的三个坐标并排一行
+    ToolField(
+      '点 B 的 y',
+      hint: '例如 2/3、Sqrt[2]、Pi',
+      keyboard: TextInputType.text,
+      rowWithPrevious: true,
+    ),
+    ToolField(
+      '点 B 的 z',
+      hint: '例如 2/3、Sqrt[2]、Pi',
+      keyboard: TextInputType.text,
+      rowWithPrevious: true,
+    ),
+    ToolField(
+      '点 C 的 x',
+      hint: '例如 2/3、Sqrt[2]、Pi',
+      keyboard: TextInputType.text,
+    ),
+    // 一个点的三个坐标并排一行
+    ToolField(
+      '点 C 的 y',
+      hint: '例如 2/3、Sqrt[2]、Pi',
+      keyboard: TextInputType.text,
+      rowWithPrevious: true,
+    ),
+    ToolField(
+      '点 C 的 z',
+      hint: '例如 2/3、Sqrt[2]、Pi',
+      keyboard: TextInputType.text,
+      rowWithPrevious: true,
+    ),
   ];
 
   /// 取第 [point] 个点(0=A、1=B、2=C)的第 [axis] 个坐标(0=x、1=y、2=z)。
@@ -39,32 +84,30 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
 
   /// 边向量 to - from,写成 Symja 的列表。每个坐标都加括号,
   /// 免得用户写的 `1 + Sqrt[3]` 和后面的减号黏在一起算错。
-  String _edge(int from, int to) => '{'
+  String _edge(int from, int to) =>
+      '{'
       '(${_coord(to, 0)}) - (${_coord(from, 0)}), '
       '(${_coord(to, 1)}) - (${_coord(from, 1)}), '
       '(${_coord(to, 2)}) - (${_coord(from, 2)})}';
 
   @override
   Widget build(BuildContext context) => ToolScaffold(
-        title: '三点法向量计算',
-        fields: _coords,
-        intro: const Text(
-          '给三个点 A、B、C,算法向量 AB × AC。\n'
-          '坐标按 Symja 语法输入:2/3(分数)、Sqrt[2](根号)、Pi,空着当 0。\n'
-          '结果是精确值,根号、分数原样保留,不会转成小数。\n'
-          '如果已知两向量坐标,把一个点设为原点即可。',
-        ),
-        compute: (session) async {
-          final ab = _edge(0, 1); // B - A
-          final ac = _edge(0, 2); // C - A
-          final r = await session.compute('Cross[$ab, $ac]');
-          return [
-            ToolText('法向量 AB × AC', r.text, tex: r.tex),
-            const ToolText(
-              '说明',
-              '法向量由向量 AB 与向量 AC 的叉积计算得到。',
-            ),
-          ];
-        },
-      );
+    title: '三点法向量计算',
+    fields: _coords,
+    intro: const Text(
+      '给三个点 A、B、C,算法向量 AB × AC。\n'
+      '坐标按 Symja 语法输入:2/3(分数)、Sqrt[2](根号)、Pi,空着当 0。\n'
+      '结果是精确值,根号、分数原样保留,不会转成小数。\n'
+      '如果已知两向量坐标,把一个点设为原点即可。',
+    ),
+    compute: (session) async {
+      final ab = _edge(0, 1); // B - A
+      final ac = _edge(0, 2); // C - A
+      final r = await session.compute('Cross[$ab, $ac]');
+      return [
+        ToolText('法向量 AB × AC', r.text, tex: r.tex),
+        const ToolText('说明', '法向量由向量 AB 与向量 AC 的叉积计算得到。'),
+      ];
+    },
+  );
 }

+ 35 - 0
test/widget_test.dart

@@ -8,6 +8,7 @@ import 'package:cal/calculator_page.dart';
 import 'package:cal/console_page.dart';
 import 'package:cal/expr_editor.dart';
 import 'package:cal/main.dart';
+import 'package:cal/tools/cuo_wei_xiang_jian.dart';
 import 'package:cal/tools/cycle_coloring.dart';
 import 'package:cal/tools/group_allocation.dart';
 import 'package:cal/tools_page.dart';
@@ -682,6 +683,40 @@ void main() {
       );
     });
 
+    test('错位相减法的结果公式:尾巴是 −B,不是 +B', () {
+      // B = −4 → 尾巴写成 `+ 4`
+      expect(
+        sFormula(a: '8', b: '-4', q: '2').plain,
+        'S(n) = (8·n - 4)·2^n + 4',
+      );
+      // B = 2 → 尾巴写成 `- 2`
+      expect(
+        sFormula(a: '4', b: '2', q: '2').plain,
+        'S(n) = (4·n + 2)·2^n - 2',
+      );
+      // LaTeX 里分数要写成 \frac
+      final tex = sFormula(a: '1/2', b: '-1/3', q: '3').tex;
+      expect(tex, contains(r'\frac{1}{2}'));
+      expect(tex, contains(r'\frac{1}{3}'));
+      expect(tex, contains('{3}^{n} + '));
+    });
+
+    testWidgets('错位相减法把 Sₙ 的公式整条渲染出来', (tester) async {
+      await openTool(tester, '错位相减法');
+      await fillTool(tester, CuoWeiXiangJianPage, ['2', '1', '2', '1']);
+      await runTool(tester);
+
+      // 结果卡在(渲染成数学排版),不是只把 A、B 两个数甩出来
+      expect(find.text('结果 Sₙ = (A·n + B)·qⁿ − B'), findsOneWidget);
+      // 乘号是显式写的:`va*vq^(vm+1)` 而不是 `va vq^(vm+1)`
+      expect(
+        calls
+            .where((c) => c.method == 'evaluate')
+            .map((c) => '${c.arguments['expr']}'),
+        contains('With[{va=2,vb=1,vq=2,vm=1}, va*vq^(vm+1)/(vq-1)]'),
+      );
+    });
+
     testWidgets('n 边形染色:(k−1)ⁿ+(k−1)(−1)ⁿ 是 Symja 算的', (tester) async {
       await openTool(tester, 'n 边形染色');
       await fillTool(tester, CycleColoringPage, ['5', '3']);