فهرست منبع

对称变换的直线方程改数学排版、圆的方程加"一般方程";绘图按钮改叫「绘制」;输入框统一数字键盘

- 对称变换(直线关于点/直线关于直线):直线方程用 LaTeX 渲染,正负号、省略
  系数 1、系数为 0 的项不写都交给新的 polyEqualsZero;系数是分数时另给一条
  小数版。顺带把 cuo_wei 里的 _frac 提成共用的 texNum
- 圆的方程:结果最前面加一条**圆的一般方程** x²+y²+Dx+Ey+F=0(系数代进去,
  LaTeX 渲染;F=0 时那一项自动不写)
- 两个绘图工具的按钮从「计算」改成「绘制」(ToolScaffold 新增 runLabel/runIcon)
- **所有工具的输入框统一用数字键盘**(原来表达式栏是全键盘):工具页绝大多数
  输入是数,输入法本身能切到字母,表达式栏照样打得进 Sin[x] —— 现在每个框点
  下去手感一致
- 补一条 polyEqualsZero 的单元测试(0 不写、±1 省 1、正负号)

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 3 هفته پیش
والد
کامیت
0d2c8e25ae

+ 0 - 1
lib/tools/bisection_method.dart

@@ -22,7 +22,6 @@ class _BisectionMethodPageState extends State<BisectionMethodPage> {
     '函数 f(x)',
     hint: '例如 x^3 - x - 2,变量用 x',
     // 函数要打字母、^、括号,不能只给数字键盘。
-    keyboard: TextInputType.text,
   );
   final _a = ToolField('区间左端点 a');
   final _b = ToolField('区间右端点 b');

+ 11 - 0
lib/tools/circle_equation.dart

@@ -72,7 +72,18 @@ class _CircleEquationPageState extends State<CircleEquationPage> {
 
       String at(List<Object> l, int i) => i < l.length ? '${l[i]}' : '';
 
+      // 一般方程 x² + y² + Dx + Ey + F = 0(系数代进去,渲染成数学排版)。
+      // 放在最前面 —— 它才是"结果",D/E/F 是它的系数。
+      final general = polyEqualsZero([
+        ('1', 'x^{2}', 'x^2'),
+        ('1', 'y^{2}', 'y^2'),
+        ('${defs[0]}', 'x', 'x'),
+        ('${defs[1]}', 'y', 'y'),
+        ('${defs[2]}', '', ''),
+      ]);
+
       final out = <ToolOutput>[
+        ToolText('圆的一般方程', general.plain, tex: general.tex),
         ToolText('D', withApprox('${defs[0]}', at(defsN, 0))),
         ToolText('E', withApprox('${defs[1]}', at(defsN, 1))),
         ToolText('F', withApprox('${defs[2]}', at(defsN, 2))),

+ 2 - 10
lib/tools/cuo_wei_xiang_jian.dart

@@ -97,8 +97,8 @@ class _CuoWeiXiangJianPageState extends State<CuoWeiXiangJianPage> {
       '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)}',
+      'S_{n} = (${texNum(a)}\\cdot n ${_signed(b, texNum)})\\cdot '
+      '{${texNum(q)}}^{n} ${_signed(_neg(b), texNum)}',
 );
 
 /// `+ 4` / `- \frac{1}{2}`:把带符号的数接到前一项后面,
@@ -115,11 +115,3 @@ 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]}}';
-}

+ 2 - 6
lib/tools/cycle_coloring.dart

@@ -13,12 +13,8 @@ class CycleColoringPage extends StatefulWidget {
 }
 
 class _CycleColoringPageState extends State<CycleColoringPage> {
-  final _n = ToolField(
-    'n (边数)',
-    hint: '不小于 3 的整数',
-    keyboard: TextInputType.number,
-  );
-  final _k = ToolField('k (颜色数)', hint: '正整数', keyboard: TextInputType.number);
+  final _n = ToolField('n (边数)', hint: '不小于 3 的整数');
+  final _k = ToolField('k (颜色数)', hint: '正整数');
 
   @override
   Widget build(BuildContext context) => ToolScaffold(

+ 4 - 10
lib/tools/function_plotter.dart

@@ -44,11 +44,7 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
   /// 可增减的表达式输入框。列表**就地增删**(对象始终是同一个),
   /// 外壳靠 `identical(fields)` 判断要不要清空结果,换新 List 会把结果清掉。
   final List<ToolField> _exprs = <ToolField>[
-    ToolField(
-      '函数 1',
-      hint: '例如 x^2、Sin[x]、Sqrt[x]',
-      keyboard: TextInputType.text,
-    ),
+    ToolField('函数 1', hint: '例如 x^2、Sin[x]、Sqrt[x]'),
   ];
 
   @override
@@ -62,11 +58,7 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
     if (_exprs.length >= _maxCurves) return;
     setState(() {
       _exprs.add(
-        ToolField(
-          '函数 ${_exprs.length + 1}',
-          hint: '例如 x^2、Sin[x]、Sqrt[x]',
-          keyboard: TextInputType.text,
-        ),
+        ToolField('函数 ${_exprs.length + 1}', hint: '例如 x^2、Sin[x]、Sqrt[x]'),
       );
     });
   }
@@ -138,6 +130,8 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
   @override
   Widget build(BuildContext context) => ToolScaffold(
     title: '函数图像绘制',
+    runLabel: '绘制',
+    runIcon: Icons.show_chart,
     // 默认已经有一个 x^2,打开就该看到图。
     autoRun: true,
     fields: _exprs,

+ 2 - 2
lib/tools/group_allocation.dart

@@ -14,8 +14,8 @@ class GroupAllocationPage extends StatefulWidget {
 }
 
 class _GroupAllocationPageState extends State<GroupAllocationPage> {
-  final _n = ToolField('n (人数)', hint: '正整数', keyboard: TextInputType.number);
-  final _k = ToolField('k (组数)', hint: '正整数', keyboard: TextInputType.number);
+  final _n = ToolField('n (人数)', hint: '正整数');
+  final _k = ToolField('k (组数)', hint: '正整数');
 
   @override
   Widget build(BuildContext context) => ToolScaffold(

+ 4 - 10
lib/tools/parametric_plotter.dart

@@ -18,16 +18,8 @@ class ParametricPlotterPage extends StatefulWidget {
 }
 
 class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
-  final ToolField _x = ToolField(
-    'x = f(t)',
-    hint: '例如 cos(t)、t^2',
-    keyboard: TextInputType.text,
-  );
-  final ToolField _y = ToolField(
-    'y = g(t)',
-    hint: '例如 sin(t)、t^3',
-    keyboard: TextInputType.text,
-  );
+  final ToolField _x = ToolField('x = f(t)', hint: '例如 cos(t)、t^2');
+  final ToolField _y = ToolField('y = g(t)', hint: '例如 sin(t)、t^3');
   final ToolField _tMin = ToolField('t 最小值', hint: '例如 0');
   final ToolField _tMax = ToolField('t 最大值', hint: '例如 6.3');
   final ToolField _step = ToolField('步长', hint: '例如 0.1');
@@ -99,6 +91,8 @@ class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
     // key 一换,外壳整体重建并重新 autoRun —— 示例按钮靠它重算。
     key: ValueKey<int>(_runToken),
     title: '参数方程绘图',
+    runLabel: '绘制',
+    runIcon: Icons.show_chart,
     autoRun: true,
     fields: _fields,
     intro: _intro(),

+ 1 - 5
lib/tools/statistical_charts.dart

@@ -49,11 +49,7 @@ class _StatisticalChartsPageState extends State<StatisticalChartsPage> {
 
   void _addRow({String initialLabel = '新数据', String initialValue = '0'}) {
     final n = _rowCount + 1;
-    final label = ToolField(
-      '第 $n 项标签',
-      hint: '数据名',
-      keyboard: TextInputType.text,
-    );
+    final label = ToolField('第 $n 项标签', hint: '数据名');
     final value = ToolField('第 $n 项数值', hint: '例如 10');
     label.controller.text = initialLabel;
     value.controller.text = initialValue;

+ 29 - 11
lib/tools/symmetry.dart

@@ -101,7 +101,8 @@ class _SymmetryPageState extends State<SymmetryPage> {
           final expr =
               'With[{A=$a,B=$b,C=$c,x=$x,y=$y}, '
               '{A, B, -2*A*x-2*B*y-C}]';
-          return [_line(await _withApprox(session, expr))];
+          final (r, n) = await _exactAndApprox(session, expr);
+          return _line(r, n);
         }
 
         // 原直线 Px + Qy + R = 0 关于对称轴 Ax + By + C = 0
@@ -114,31 +115,48 @@ class _SymmetryPageState extends State<SymmetryPage> {
         final expr =
             'With[{P=$p,Q=$q,R=$rr,A=$a,B=$b,C=$c}, '
             'Module[{k=2*(P*A+Q*B)/(A^2+B^2)}, {P-A*k, Q-B*k, R-C*k}]]';
-        return [_line(await _withApprox(session, expr))];
+        final (r, n) = await _exactAndApprox(session, expr);
+        return _line(r, n);
       },
     );
   }
 
-  /// 精确值 + 近似值:每个分量各自配上自己的小数。
-  Future<List<Object>> _withApprox(SymjaSession session, String expr) async {
+  /// 一次拿两组:精确值(原样)和小数版(截到设置的位数)。
+  Future<(List<Object>, List<Object>)> _exactAndApprox(
+    SymjaSession session,
+    String expr,
+  ) async {
     final r = parseSymjaList((await session.compute(expr)).text);
     final n = parseSymjaList(
       (await session.compute(
         approxExpr('Rationalize[$expr]', session.approximateDigits),
       )).text,
     );
-    return [
-      for (var i = 0; i < r.length; i++)
-        withApprox('${r[i]}', i < n.length ? '${n[i]}' : ''),
-    ];
+    return (r, n);
   }
 
   String _pair(List<Object> r, List<Object> n, int i) =>
       withApprox('${r[i]}', i < n.length ? '${n[i]}' : '');
 
-  /// 把 Symja 回来的一串 {A, B, C} 拼成直线方程文本。
-  ToolOutput _line(List<Object> r) =>
-      ToolText('对称直线方程', '${r[0]}·x + ${r[1]}·y + ${r[2]} = 0');
+  /// 把 Symja 回来的一串 {A, B, C} 拼成直线方程 —— 渲染成数学排版。
+  ///
+  /// 系数的正负交给 polyEqualsZero 处理(`+ -1·y` 要写成 `- y`),
+  /// 另外给一条小数版:系数是分数时两个形式都有用。
+  List<ToolOutput> _line(List<Object> exact, List<Object> approx) {
+    ({String plain, String tex}) eq(List<Object> r) => polyEqualsZero([
+      ('${r[0]}', 'x', 'x'),
+      ('${r[1]}', 'y', 'y'),
+      ('${r[2]}', '', ''),
+    ]);
+
+    final e = eq(exact);
+    final out = [ToolText('对称直线方程', e.plain, tex: e.tex)];
+    final a = eq(approx);
+    if (a.plain != e.plain) {
+      out.add(ToolText('对称直线方程(近似)', a.plain, tex: a.tex));
+    }
+    return out;
+  }
 
   /// 取一个标量输入框:空着或填了多个值都直接报中文错。
   String _need(ToolField f) {

+ 67 - 2
lib/tools/tool_kit.dart

@@ -23,6 +23,8 @@ class ToolScaffold extends StatefulWidget {
     required this.compute,
     this.intro,
     this.beforeField = const {},
+    this.runLabel = '计算',
+    this.runIcon,
     this.autoRun = false,
   });
 
@@ -42,6 +44,12 @@ class ToolScaffold extends StatefulWidget {
   /// 工具自己的说明(公式长什么样、参数什么含义),显示在输入框上方。
   final Widget? intro;
 
+  /// 主按钮的文字。绘图类工具写「绘制」比「计算」贴切。
+  final String runLabel;
+
+  /// 主按钮的图标,不给就按"计算"用算盘图标。
+  final IconData? runIcon;
+
   /// 在第 n 个输入框**前面**插一块自定义 widget(下标按 [fields] 算)。
   ///
   /// 卷积那种"一组输入框配一组增删按钮"用它 —— 按钮得贴着**自己那一组**,
@@ -190,8 +198,8 @@ class _ToolScaffoldState extends State<ToolScaffold> {
               Expanded(
                 child: FilledButton.icon(
                   onPressed: _busy ? null : _run,
-                  icon: const Icon(Icons.calculate_outlined),
-                  label: const Text('计算'),
+                  icon: Icon(widget.runIcon ?? Icons.calculate_outlined),
+                  label: Text(widget.runLabel),
                 ),
               ),
               const SizedBox(width: 12),
@@ -256,6 +264,10 @@ class ToolField {
 
   final String label;
   final String hint;
+
+  /// 输入法键盘。**默认一律数字键盘**(带符号和小数点):工具页绝大多数输入
+  /// 是数,而输入法本身能切到字母 —— 表达式栏照样打得进 `Sin[x]`。
+  /// 这样每个框点下去的手感一致,不会有的弹数字、有的弹全键盘。
   final TextInputType keyboard;
   final TextEditingController controller;
 
@@ -472,6 +484,59 @@ Future<ToolOutput> numeric(
   return ToolText(label, r.text, tex: null);
 }
 
+/// 数值文本 → LaTeX:分数写成 `\frac{a}{b}`,其余原样。值都是 Symja 给的,这里只排版。
+String texNum(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"的等式(直线方程、圆的一般方程都用它)。
+///
+/// 每项是 `(系数, 变量部分的 LaTeX, 变量部分的纯文本)` —— 变量部分留空就是常数项;
+/// 系数是 Symja 算出来的**精确**值原文(`-2`、`1/2` …)。会自动处理:
+///   · 系数是 0 的项不写(圆的 F = 0 时那一项就不该出现)
+///   · 系数 ±1 又带变量时省略 1(写 `x` 而不是 `1·x`)
+///   · 首项不带正号,其余项按正负接 `+` / `-`
+({String plain, String tex}) polyEqualsZero(
+  List<(String coef, String varTex, String varPlain)> terms,
+) {
+  final plain = StringBuffer();
+  final tex = StringBuffer();
+  var first = true;
+
+  for (final (coef, varTex, varPlain) in terms) {
+    final t = coef.trim();
+    final n = asNumber(t);
+    if (n != null && n == 0) continue; // 系数为 0:整项不写
+
+    final neg = t.startsWith('-');
+    final mag = neg ? t.substring(1) : t;
+    // 系数是 ±1 又带变量时,1 不写
+    final hideOne = varPlain.isNotEmpty && mag == '1';
+
+    final sign = first ? (neg ? '-' : '') : (neg ? ' - ' : ' + ');
+    plain.write(sign);
+    tex.write(sign);
+
+    if (!hideOne) {
+      plain.write(varPlain.isEmpty ? mag : '$mag·');
+      tex.write(texNum(mag));
+      if (varPlain.isNotEmpty) tex.write(r' \cdot ');
+    }
+    plain.write(varPlain);
+    tex.write(varTex);
+    first = false;
+  }
+
+  if (first) {
+    plain.write('0');
+    tex.write('0');
+  }
+  return (plain: '$plain = 0', tex: '$tex = 0');
+}
+
 /// 把 Symja 输出的列表文本拆成嵌套的字符串列表。
 ///
 /// 工具要拿到"一条条"结果(比如每个 k 的概率、每对卷积取值)才能分开显示,

+ 9 - 51
lib/tools/vector_normal.dart

@@ -19,60 +19,18 @@ 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 的 x', hint: '例如 2/3、Sqrt[2]、Pi'),
     // 一个点的三个坐标并排一行
-    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('点 A 的 y', hint: '例如 2/3、Sqrt[2]、Pi', rowWithPrevious: true),
+    ToolField('点 A 的 z', hint: '例如 2/3、Sqrt[2]、Pi', rowWithPrevious: true),
+    ToolField('点 B 的 x', hint: '例如 2/3、Sqrt[2]、Pi'),
     // 一个点的三个坐标并排一行
-    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('点 B 的 y', hint: '例如 2/3、Sqrt[2]、Pi', rowWithPrevious: true),
+    ToolField('点 B 的 z', hint: '例如 2/3、Sqrt[2]、Pi', rowWithPrevious: true),
+    ToolField('点 C 的 x', hint: '例如 2/3、Sqrt[2]、Pi'),
     // 一个点的三个坐标并排一行
-    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,
-    ),
+    ToolField('点 C 的 y', hint: '例如 2/3、Sqrt[2]、Pi', rowWithPrevious: true),
+    ToolField('点 C 的 z', hint: '例如 2/3、Sqrt[2]、Pi', rowWithPrevious: true),
   ];
 
   /// 取第 [point] 个点(0=A、1=B、2=C)的第 [axis] 个坐标(0=x、1=y、2=z)。

+ 34 - 1
test/widget_test.dart

@@ -11,6 +11,7 @@ import 'package:cal/expr_editor.dart';
 import 'package:cal/main.dart';
 import 'package:cal/settings.dart';
 import 'package:cal/tools/cuo_wei_xiang_jian.dart';
+import 'package:cal/tools/tool_kit.dart';
 import 'package:cal/tools/cycle_coloring.dart';
 import 'package:cal/tools/function_plotter.dart';
 import 'package:cal/tools/group_allocation.dart';
@@ -148,8 +149,12 @@ void main() {
     await tester.pumpAndSettle();
   }
 
+  /// 按工具页的主按钮。绘图类工具上写的是「绘制」。
   Future<void> runTool(WidgetTester tester) async {
-    await tester.tap(find.widgetWithText(FilledButton, '计算'));
+    final calc = find.widgetWithText(FilledButton, '计算');
+    await tester.tap(
+      calc.evaluate().isEmpty ? find.widgetWithText(FilledButton, '绘制') : calc,
+    );
     await tester.pumpAndSettle();
   }
 
@@ -677,6 +682,34 @@ void main() {
       );
     });
 
+    test('直线/圆的方程拼装:0 不写、±1 省 1、正负号对', () {
+      // 圆 x² + y² - 2x - 2y + 0 = 0:F = 0 那一项不该出现
+      final circle = polyEqualsZero([
+        ('1', 'x^{2}', 'x^2'),
+        ('1', 'y^{2}', 'y^2'),
+        ('-2', 'x', 'x'),
+        ('-2', 'y', 'y'),
+        ('0', '', ''),
+      ]);
+      expect(circle.plain, 'x^2 + y^2 - 2·x - 2·y = 0');
+
+      // 直线 -x + 2y - 1 = 0:系数 ±1 时不写 1
+      final line = polyEqualsZero([
+        ('-1', 'x', 'x'),
+        ('2', 'y', 'y'),
+        ('-1', '', ''),
+      ]);
+      expect(line.plain, '-x + 2·y - 1 = 0');
+
+      // 全是 0(退化)也要给出个像样的东西
+      expect(polyEqualsZero([('0', 'x', 'x')]).plain, '0 = 0');
+
+      // 分数系数:LaTeX 里要写成 \frac
+      final frac = polyEqualsZero([('1/2', 'x', 'x'), ('1', 'y', 'y')]);
+      expect(frac.plain, '1/2·x + y = 0');
+      expect(frac.tex, contains(r'\frac{1}{2}'));
+    });
+
     testWidgets('卷积:每组的增删按钮贴着自己那组', (tester) async {
       await openTool(tester, '卷积分布列');