Quellcode durchsuchen

绘图统一到一套栈;MCP 绘图工具返回图片;移除导数计算器

## 绘图去重
工具页的 函数图像绘制 / 参数方程绘图 原来用 math_expressions + fl_chart,
和计算器/控制台用的 Symja + CurvePlot 是两套完全独立的绘图栈。现在统一到后者,
两个工具保留各自的表单能力(最多 5 个函数同图、x/y 范围、参数方程的四个预设)。

输入语法做了兼容层(lib/expr_syntax.dart):老的小写括号写法 `sin(x)` 仍然
可用,会转成 `Sin[x]`;**界面上会显示转换后的表达式**,转换错了用户看得见。
科学计数法(`1e-3`)不会被误拆。15 个单元测试钉住了这些边界。

顺带给 CurvePlot 加了 yMin/yMax:自动定界碰上带极点的函数(Tan[x])会被几个
大值撑爆整幅图,而函数图像绘制原本就能填 y 范围——不接住的话那两个输入框
就成了能点但没用的死控件。

## MCP 绘图返回图片
MCP 协议支持 image 内容块,服务器又跑在 App 进程里,所以直接用
android.graphics 把坐标光栅化成 PNG(Symja 自带的渲染器走 Java2D/AWT,
Android 上没有)。现在三个绘图工具返回 [text(JSON 坐标), image(PNG)] 两个块。

## 移除导数计算器插件
计算器的 d/dx 键和 Symja 的 D[] 已经覆盖了它。工具从 16 个减到 15 个。

真机验证(Android 14):函数图像绘制画出抛物线且 y 范围生效、参数方程画出圆弧
并显示语法转换提示、MCP plot 返回 65 点 JSON + 14351 字节 PNG。

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce vor 4 Wochen
Ursprung
Commit
d7e2ce17fa

+ 27 - 5
android/app/src/main/java/com/nonvioce/cal/McpServer.java

@@ -273,9 +273,11 @@ public class McpServer extends NanoHTTPD {
 
         // --- Plotting ---
         tools.put(tool("plot",
-            "Plot a function f[x] over a range. Returns structured plot data as JSON: " +
-            "{type:'plot', elements:[{type:'line'|'point', color:'#RRGGBB', " +
-            "points:[[x,y],...]}], options:{...}}. Use Symja math syntax.",
+            "Plot a function f[x] over a range. Returns BOTH: (1) a text block with " +
+            "structured plot data as JSON {type:'plot', elements:[{type:'line'|'point', " +
+            "color:'#RRGGBB', points:[[x,y],...]}], options:{...}}, and (2) an image block " +
+            "with the rendered curve as a PNG, so you can see the plot directly. " +
+            "Use Symja math syntax.",
             inputSchema(
                 props()
                     .put("function", stringProp("Function expression, e.g. Sin[x] or x^2"))
@@ -286,7 +288,7 @@ public class McpServer extends NanoHTTPD {
 
         tools.put(tool("parametric_plot",
             "Plot a parametric curve (x(t), y(t)). Returns structured plot data as JSON " +
-            "(same shape as the plot tool).",
+            "plus a rendered PNG image (same shape as the plot tool).",
             inputSchema(
                 props()
                     .put("x_func", stringProp("x(t) expression, e.g. Sin[t]"))
@@ -298,7 +300,7 @@ public class McpServer extends NanoHTTPD {
 
         tools.put(tool("list_plot",
             "Plot a list of points as a scatter plot. Returns structured plot data as JSON " +
-            "(same shape as the plot tool, elements are of type 'point').",
+            "plus a rendered PNG image (elements are of type 'point').",
             inputSchema(
                 props()
                     .put("points", stringProp(
@@ -312,6 +314,13 @@ public class McpServer extends NanoHTTPD {
 
     // ── tools/call ────────────────────────────────────────────────
 
+    /** 这三个工具的返回值是绘图 JSON,可以再渲染成 PNG。 */
+    private static boolean isPlotTool(String toolName) {
+        return "plot".equals(toolName)
+                || "parametric_plot".equals(toolName)
+                || "list_plot".equals(toolName);
+    }
+
     private JSONObject handleToolsCall(JSONObject params) throws JSONException {
         String name = params.getString("name");
         JSONObject arguments = params.optJSONObject("arguments");
@@ -381,6 +390,19 @@ public class McpServer extends NanoHTTPD {
         item.put("text", textResult);
         content.put(item);
 
+        // 绘图工具额外附一张 PNG —— 客户端既能拿坐标自己处理,也能直接"看到"图。
+        // 渲染失败就只回坐标,不影响调用成功。
+        if (isPlotTool(name) && textResult.startsWith("{")) {
+            String png = PlotImage.renderPngBase64(textResult, 640, 400);
+            if (png != null) {
+                JSONObject img = new JSONObject();
+                img.put("type", "image");
+                img.put("data", png);
+                img.put("mimeType", "image/png");
+                content.put(img);
+            }
+        }
+
         JSONObject result = new JSONObject();
         result.put("content", content);
         // MCP spec: isError at result top level

+ 212 - 0
android/app/src/main/java/com/nonvioce/cal/PlotImage.java

@@ -0,0 +1,212 @@
+package com.nonvioce.cal;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Path;
+import android.util.Base64;
+import android.util.Log;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayOutputStream;
+
+/**
+ * 把绘图数据渲染成 PNG,供 MCP 以图片内容块返回给 AI 客户端。
+ *
+ * 为什么自己画:Symja 自带的渲染器走 Java2D/AWT,Android 上根本没有
+ * (Config.JAVA_AWT_DESKTOP_AVAILABLE 在 Android 上是 false)。
+ * 但 MCP 服务器就跑在 App 进程里,可以直接用 android.graphics,
+ * 所以自己画一条折线图不难,还能顺手把坐标轴画上。
+ *
+ * 输入是 {@link SymjaBridge#plotSync} 产生的 JSON:
+ * {"type":"plot","elements":[{"type":"line"|"point","color":"#RRGGBB","points":[[x,y],...]}],...}
+ */
+public final class PlotImage {
+
+    private static final String TAG = "PlotImage";
+
+    private static final int PAD_LEFT = 52;
+    private static final int PAD_RIGHT = 14;
+    private static final int PAD_TOP = 14;
+    private static final int PAD_BOTTOM = 30;
+
+    private PlotImage() {
+    }
+
+    /** 渲染成 base64 编码的 PNG。任何失败都返回 null(MCP 那边就只回坐标)。 */
+    public static String renderPngBase64(String plotJson, int width, int height) {
+        try {
+            JSONObject root = new JSONObject(plotJson);
+            JSONArray elements = root.optJSONArray("elements");
+            if (elements == null || elements.length() == 0) return null;
+
+            // ── 定界 ──────────────────────────────────────────────
+            double x0 = Double.POSITIVE_INFINITY, x1 = Double.NEGATIVE_INFINITY;
+            double y0 = Double.POSITIVE_INFINITY, y1 = Double.NEGATIVE_INFINITY;
+            for (int e = 0; e < elements.length(); e++) {
+                JSONArray pts = elements.getJSONObject(e).optJSONArray("points");
+                if (pts == null) continue;
+                for (int i = 0; i < pts.length(); i++) {
+                    JSONArray p = pts.getJSONArray(i);
+                    double x = p.getDouble(0);
+                    double y = p.getDouble(1);
+                    if (Double.isNaN(x) || Double.isNaN(y)) continue;
+                    if (Double.isInfinite(x) || Double.isInfinite(y)) continue;
+                    x0 = Math.min(x0, x);
+                    x1 = Math.max(x1, x);
+                    y0 = Math.min(y0, y);
+                    y1 = Math.max(y1, y);
+                }
+            }
+            if (!isFinite(x0) || !isFinite(y0)) return null;   // 一个有限点都没有
+
+            if (x1 - x0 < 1e-12) { x0 -= 1; x1 += 1; }
+            if (y1 - y0 < 1e-12) { y0 -= 1; y1 += 1; }
+            // 上下各留 5% 余量,别让曲线顶到边
+            double yPad = (y1 - y0) * 0.05;
+            y0 -= yPad;
+            y1 += yPad;
+
+            Bitmap bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+            Canvas canvas = new Canvas(bmp);
+            canvas.drawColor(Color.WHITE);
+
+            final double fx0 = x0, fx1 = x1, fy0 = y0, fy1 = y1;
+            float plotL = PAD_LEFT, plotR = width - PAD_RIGHT;
+            float plotT = PAD_TOP, plotB = height - PAD_BOTTOM;
+
+            // 数据坐标 -> 画布坐标
+            class Map {
+                float x(double v) { return (float) (plotL + (v - fx0) / (fx1 - fx0) * (plotR - plotL)); }
+                float y(double v) { return (float) (plotB - (v - fy0) / (fy1 - fy0) * (plotB - plotT)); }
+            }
+            Map m = new Map();
+
+            Paint grid = new Paint(Paint.ANTI_ALIAS_FLAG);
+            grid.setColor(0xFFE8E8EE);
+            grid.setStrokeWidth(1f);
+
+            Paint axis = new Paint(Paint.ANTI_ALIAS_FLAG);
+            axis.setColor(0xFF9E9E9E);
+            axis.setStrokeWidth(1.5f);
+
+            Paint text = new Paint(Paint.ANTI_ALIAS_FLAG);
+            text.setColor(0xFF757575);
+            text.setTextSize(11f);
+
+            // ── 网格与刻度 ────────────────────────────────────────
+            double stepX = niceStep(fx1 - fx0);
+            for (double v = Math.ceil(fx0 / stepX) * stepX; v <= fx1; v += stepX) {
+                float px = m.x(v);
+                canvas.drawLine(px, plotT, px, plotB, grid);
+                canvas.drawText(fmt(v), px - 10, plotB + 14, text);
+            }
+            double stepY = niceStep(fy1 - fy0);
+            for (double v = Math.ceil(fy0 / stepY) * stepY; v <= fy1; v += stepY) {
+                float py = m.y(v);
+                canvas.drawLine(plotL, py, plotR, py, grid);
+                canvas.drawText(fmt(v), 4, py + 4, text);
+            }
+
+            // ── 坐标轴 ────────────────────────────────────────────
+            if (fy0 <= 0 && fy1 >= 0) canvas.drawLine(plotL, m.y(0), plotR, m.y(0), axis);
+            if (fx0 <= 0 && fx1 >= 0) canvas.drawLine(m.x(0), plotT, m.x(0), plotB, axis);
+
+            // ── 曲线(超出边界就裁掉,别画到坐标轴外面)──────────────
+            canvas.save();
+            canvas.clipRect(plotL, plotT, plotR, plotB);
+            for (int e = 0; e < elements.length(); e++) {
+                JSONObject el = elements.getJSONObject(e);
+                JSONArray pts = el.optJSONArray("points");
+                if (pts == null || pts.length() == 0) continue;
+
+                boolean isPoint = "point".equals(el.optString("type", "line"));
+                Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+                paint.setColor(parseColor(el.optString("color", "#3F51B5")));
+
+                if (isPoint) {
+                    paint.setStyle(Paint.Style.FILL);
+                    for (int i = 0; i < pts.length(); i++) {
+                        JSONArray p = pts.getJSONArray(i);
+                        double x = p.getDouble(0), y = p.getDouble(1);
+                        if (!finite(x) || !finite(y)) continue;
+                        canvas.drawCircle(m.x(x), m.y(y), 4f, paint);
+                    }
+                } else {
+                    paint.setStyle(Paint.Style.STROKE);
+                    paint.setStrokeWidth(2.5f);
+                    paint.setStrokeJoin(Paint.Join.ROUND);
+                    Path path = new Path();
+                    boolean pen = false;
+                    for (int i = 0; i < pts.length(); i++) {
+                        JSONArray p = pts.getJSONArray(i);
+                        double x = p.getDouble(0), y = p.getDouble(1);
+                        if (!finite(x) || !finite(y)) {
+                            pen = false;   // 断点(极点)抬笔
+                            continue;
+                        }
+                        float px = m.x(x), py = m.y(y);
+                        if (!pen) {
+                            path.moveTo(px, py);
+                            pen = true;
+                        } else {
+                            path.lineTo(px, py);
+                        }
+                    }
+                    canvas.drawPath(path, paint);
+                }
+            }
+            canvas.restore();
+
+            ByteArrayOutputStream out = new ByteArrayOutputStream();
+            bmp.compress(Bitmap.CompressFormat.PNG, 100, out);
+            bmp.recycle();
+            return Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP);
+
+        } catch (JSONException | RuntimeException ex) {
+            Log.w(TAG, "绘图 PNG 渲染失败,只回坐标", ex);
+            return null;
+        }
+    }
+
+    private static boolean finite(double v) {
+        return !Double.isNaN(v) && !Double.isInfinite(v);
+    }
+
+    private static boolean isFinite(double v) {
+        return finite(v);
+    }
+
+    private static int parseColor(String s) {
+        try {
+            return Color.parseColor(s);
+        } catch (RuntimeException e) {
+            return 0xFF3F51B5;
+        }
+    }
+
+    /** 取 1/2/5 这种"好看"的刻度间隔。 */
+    private static double niceStep(double span) {
+        if (!finite(span) || span <= 0) return 1;
+        double raw = span / 6.0;
+        double mag = Math.pow(10, Math.floor(Math.log10(raw)));
+        for (double mm : new double[]{1, 2, 5, 10}) {
+            if (raw <= mm * mag) return mm * mag;
+        }
+        return 10 * mag;
+    }
+
+    private static String fmt(double v) {
+        if (v == 0) return "0";
+        double a = Math.abs(v);
+        if (a >= 1e5 || a < 1e-3) return String.format("%.1e", v);
+        String s = String.format("%.3f", v);
+        s = s.replaceAll("0+$", "");
+        s = s.replaceAll("\\.$", "");
+        return s;
+    }
+}

+ 31 - 4
lib/curve_plot.dart

@@ -25,10 +25,22 @@ class CurveSeries {
 /// mathtool2 搬过来的「函数图像绘制 / 参数方程绘图」也换成它,
 /// 避免维护两套绘图。
 class CurvePlot extends StatefulWidget {
-  const CurvePlot({super.key, required this.series});
+  const CurvePlot({
+    super.key,
+    required this.series,
+    this.yMin,
+    this.yMax,
+  });
 
   final List<CurveSeries> series;
 
+  /// 显式指定 y 轴范围。不给就按数据自动定界。
+  ///
+  /// 为什么需要:自动定界对有极点的函数很难看(`Tan[x]` 会被几个巨大的有限值
+  /// 撑爆整幅图)。`函数图像绘制` 工具原本就能填 y 范围,这个参数把它接住。
+  final double? yMin;
+  final double? yMax;
+
   @override
   State<CurvePlot> createState() => _CurvePlotState();
 }
@@ -58,7 +70,10 @@ class _CurvePlotState extends State<CurvePlot> {
       }),
       child: ClipRect(
         child: CustomPaint(
-          painter: _CurvePainter(widget.series, _zoom, _pan),
+          painter: _CurvePainter(
+            widget.series, _zoom, _pan,
+            yMin: widget.yMin, yMax: widget.yMax,
+          ),
           child: const SizedBox.expand(),
         ),
       ),
@@ -67,11 +82,13 @@ class _CurvePlotState extends State<CurvePlot> {
 }
 
 class _CurvePainter extends CustomPainter {
-  _CurvePainter(this.series, this.zoom, this.pan);
+  _CurvePainter(this.series, this.zoom, this.pan, {this.yMin, this.yMax});
 
   final List<CurveSeries> series;
   final double zoom;
   final Offset pan;
+  final double? yMin;
+  final double? yMax;
 
   static const _padLeft = 46.0;
   static const _padRight = 12.0;
@@ -105,6 +122,12 @@ class _CurvePainter extends CustomPainter {
       x0 -= 1;
       x1 += 1;
     }
+    // 显式给了 y 范围就用它 —— 自动定界碰上带极点的函数会被几个大值撑爆(Tan[x] 那种)
+    final fixedYMin = yMin;
+    final fixedYMax = yMax;
+    if (fixedYMin != null) y0 = fixedYMin;
+    if (fixedYMax != null) y1 = fixedYMax;
+
     if (y1 - y0 < 1e-12) {
       y0 -= 1;
       y1 += 1;
@@ -268,5 +291,9 @@ class _CurvePainter extends CustomPainter {
 
   @override
   bool shouldRepaint(covariant _CurvePainter old) =>
-      old.series != series || old.zoom != zoom || old.pan != pan;
+      old.series != series ||
+      old.zoom != zoom ||
+      old.pan != pan ||
+      old.yMin != yMin ||
+      old.yMax != yMax;
 }

+ 169 - 0
lib/expr_syntax.dart

@@ -0,0 +1,169 @@
+/// mathtool2 风格表达式 → Symja 语法的兼容转换。
+///
+/// 为什么需要:工具页那两个绘图工具原来用 `math_expressions`,输入是
+/// `sin(x)`、`ln(x)`、`pi` 这种小写括号写法。统一到 Symja 之后,直接扔掉这套
+/// 语法对习惯了它的人是退步,所以这里保留兼容。
+///
+/// **界面上会显示转换后的表达式** —— 转换这种事最怕悄悄改错,让用户看得见。
+///
+/// 只做**保守**的转换,不确定的一律原样保留:
+///   `sin(x)`   → `Sin[x]`      函数名(大小写不敏感)+ 紧跟的括号
+///   `pi`       → `Pi`          常量
+///   `e`        → `E`           仅当它独立成词、且不与数字相邻(避免毁掉 `1e-3`)
+///   `2x`       → `2*x`         数字紧跟字母时补乘号
+///   `(x+1)*2`  → 原样           普通括号不是函数调用,不动
+library;
+
+/// 小写函数名 → Symja 名称。
+const _functions = <String, String>{
+  'sin': 'Sin',
+  'cos': 'Cos',
+  'tan': 'Tan',
+  'cot': 'Cot',
+  'sec': 'Sec',
+  'csc': 'Csc',
+  'asin': 'ArcSin',
+  'acos': 'ArcCos',
+  'atan': 'ArcTan',
+  'arcsin': 'ArcSin',
+  'arccos': 'ArcCos',
+  'arctan': 'ArcTan',
+  'sinh': 'Sinh',
+  'cosh': 'Cosh',
+  'tanh': 'Tanh',
+  'ln': 'Log',
+  'log': 'Log',
+  'lg': 'Log10',
+  'sqrt': 'Sqrt',
+  'abs': 'Abs',
+  'exp': 'Exp',
+  'floor': 'Floor',
+  'ceil': 'Ceiling',
+  'round': 'Round',
+  'sign': 'Sign',
+  'max': 'Max',
+  'min': 'Min',
+  'mod': 'Mod',
+};
+
+const _constants = <String, String>{
+  'pi': 'Pi',
+  'inf': 'Infinity',
+  'infinity': 'Infinity',
+};
+
+bool _isLetter(String c) {
+  final u = c.codeUnitAt(0);
+  return (u >= 0x41 && u <= 0x5A) || (u >= 0x61 && u <= 0x7A) || c == '_';
+}
+
+bool _isDigit(String c) {
+  if (c.isEmpty) return false; // 空串不是数字 —— 边界处会传进来,别让它崩
+  final u = c.codeUnitAt(0);
+  return u >= 0x30 && u <= 0x39;
+}
+
+bool _isIdentChar(String c) => _isLetter(c) || _isDigit(c);
+
+/// 从 [from] 起看是不是"指数部分":可选正负号后紧跟数字。
+bool _looksLikeExponent(String s, int from) {
+  var k = from;
+  if (k < s.length && (s[k] == '+' || s[k] == '-')) k++;
+  return k < s.length && _isDigit(s[k]);
+}
+
+/// 把 [input] 转换成 Symja 语法。无法识别的地方原样保留。
+String toSymjaSyntax(String input) {
+  final out = StringBuffer();
+  // 括号栈:记住每个左括号应该被闭合成什么
+  final closers = <String>[];
+  var i = 0;
+
+  while (i < input.length) {
+    final c = input[i];
+
+    if (_isLetter(c)) {
+      var j = i;
+      while (j < input.length && _isIdentChar(input[j])) {
+        j++;
+      }
+      final word = input.substring(i, j);
+
+      // 跳过空白看后面是不是紧跟 '('
+      var k = j;
+      while (k < input.length && input[k] == ' ') {
+        k++;
+      }
+      final isCall = k < input.length && input[k] == '(';
+      final fn = _functions[word.toLowerCase()];
+
+      if (isCall && fn != null) {
+        out.write(fn);
+        out.write('[');
+        closers.add(']');
+        i = k + 1; // 吃掉 '('
+        continue;
+      }
+
+      // 常量:'e' 要额外小心——别把 1e-3 这种科学计数法拆了
+      final lower = word.toLowerCase();
+      if (lower == 'e') {
+        final prev = out.isEmpty ? '' : out.toString()[out.length - 1];
+        final next = j < input.length ? input[j] : '';
+        if (!_isDigit(prev) && !_isDigit(next)) { // 空串会被 _isDigit 判成非数字,正是想要的
+          out.write('E');
+          i = j;
+          continue;
+        }
+      } else if (_constants[lower] != null) {
+        out.write(_constants[lower]);
+        i = j;
+        continue;
+      }
+
+      out.write(word);
+      i = j;
+      continue;
+    }
+
+    if (c == '(') {
+      out.write('(');
+      closers.add(')');
+      i++;
+      continue;
+    }
+    if (c == ')') {
+      out.write(closers.isNotEmpty ? closers.removeLast() : ')');
+      i++;
+      continue;
+    }
+
+    // `2x` -> `2*x`:数字后面紧跟字母时补乘号
+    if (_isDigit(c)) {
+      var j = i;
+      while (j < input.length && (_isDigit(input[j]) || input[j] == '.')) {
+        j++;
+      }
+      out.write(input.substring(i, j));
+      if (j < input.length && _isLetter(input[j])) {
+        // 例外:`1e-3`、`2.5E10` 是科学计数法,补了乘号就毁了
+        final isExponent = (input[j] == 'e' || input[j] == 'E') &&
+            _looksLikeExponent(input, j + 1);
+        if (!isExponent) out.write('*');
+      }
+      i = j;
+      continue;
+    }
+
+    out.write(c);
+    i++;
+  }
+
+  return out.toString();
+}
+
+/// 转换过(即与原串不同)才返回新串,否则返回 null —— 界面据此决定要不要提示。
+String? symjaRewriteOf(String input) {
+  final converted = toSymjaSyntax(input);
+  return converted == input ? null : converted;
+}

+ 0 - 7
lib/plugins.dart

@@ -6,7 +6,6 @@ import 'tools/chi_square.dart';
 import 'tools/circle_equation.dart';
 import 'tools/convolution_calculator.dart';
 import 'tools/cuo_wei_xiang_jian.dart';
-import 'tools/derivative_calculator.dart';
 import 'tools/function_plotter.dart';
 import 'tools/histogram.dart';
 import 'tools/hypergeometric_distribution.dart';
@@ -150,10 +149,4 @@ final List<ToolPlugin> kTools = [
     keywords: ['parametric', 'csfc', '绘图'],
     build: () => ParametricPlotterPage(),
   ),
-  ToolPlugin(
-    name: '导数计算器',
-    icon: Icons.calculate_outlined,
-    keywords: ['derivative', 'dsjs', '微积分'],
-    build: () => DerivativeCalculatorPage(),
-  ),
 ];

+ 0 - 354
lib/tools/derivative_calculator.dart

@@ -1,354 +0,0 @@
-// tools/derivative_calculator.dart
-import 'package:flutter/material.dart';
-import 'package:math_expressions/math_expressions.dart';
-
-class DerivativeCalculatorPage extends StatefulWidget {
-  @override
-  _DerivativeCalculatorPageState createState() => _DerivativeCalculatorPageState();
-}
-
-class _DerivativeCalculatorPageState extends State<DerivativeCalculatorPage> {
-  final TextEditingController _functionController = TextEditingController();
-  final TextEditingController _pointController = TextEditingController();
-  String _result = '';
-  String _derivative = '';
-  bool _showPointInput = false;
-
-  @override
-  void dispose() {
-    _functionController.dispose();
-    _pointController.dispose();
-    super.dispose();
-  }
-
-  void _calculateDerivative() {
-    final String functionText = _functionController.text.trim();
-
-    if (functionText.isEmpty) {
-      setState(() {
-        _result = '请输入函数表达式';
-      _derivative = '';
-      });
-      return;
-    }
-
-    try {
-      // 解析函数表达式
-      Parser p = Parser();
-      Expression exp = p.parse(functionText);
-
-      // 对x求导
-      Expression deriv = exp.derive('x');
-
-      // 简化导数表达式
-      Expression simplifiedDeriv = deriv.simplify();
-
-      setState(() {
-        _derivative = '导函数: ${simplifiedDeriv.toString()}';
-
-      // 如果用户要求计算某点的导数值
-      if (_showPointInput && _pointController.text.isNotEmpty) {
-        try {
-          double point = double.parse(_pointController.text);
-          ContextModel cm = ContextModel();
-          cm.bindVariable(Variable('x'), Number(point));
-          double value = simplifiedDeriv.evaluate(EvaluationType.REAL, cm);
-          _result = '在 x = $point 处的导数值: ${value.toStringAsFixed(4)}';
-        } catch (e) {
-          _result = '点的格式错误,请输入有效的数字';
-        }
-      } else {
-        _result = _derivative;
-      }
-      });
-    } catch (e) {
-      setState(() {
-        _result = '表达式解析错误,请检查格式\n支持的格式示例: x^2 + 3*x + sin(x)';
-      _derivative = '';
-      });
-    }
-  }
-
-  void _clearAll() {
-    setState(() {
-      _functionController.clear();
-      _pointController.clear();
-      _result = '';
-    _derivative = '';
-    });
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(
-        title: Text('导数计算器', style: TextStyle(color: Colors.black)),
-        backgroundColor: Colors.white,
-        elevation: 1,
-        actions: [
-          IconButton(
-            icon: Icon(Icons.info_outline),
-            onPressed: () {
-              _showInstructions();
-            },
-          ),
-        ],
-      ),
-      body: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            Text(
-              '函数表达式 (以x为变量):',
-              style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
-            ),
-            SizedBox(height: 8),
-            TextField(
-              controller: _functionController,
-              decoration: InputDecoration(
-                hintText: '例如: x^2 + 3*x + sin(x)',
-                border: OutlineInputBorder(),
-                suffixIcon: IconButton(
-                  icon: Icon(Icons.info),
-                  onPressed: _showExpressionHelp,
-                ),
-              ),
-            ),
-            SizedBox(height: 16),
-
-            // 计算某点导数值的选项
-            Row(
-              children: [
-                Checkbox(
-                  value: _showPointInput,
-                  onChanged: (value) {
-                    setState(() {
-                      _showPointInput = value!;
-                      if (!value) {
-                        _pointController.clear();
-                      }
-                    });
-                  },
-                ),
-                Text('计算某点处的导数值'),
-              ],
-            ),
-
-            if (_showPointInput) ...[
-              SizedBox(height: 8),
-              Text(
-                '输入点的x坐标:',
-                style: TextStyle(fontSize: 14),
-              ),
-              SizedBox(height: 8),
-              TextField(
-                controller: _pointController,
-                decoration: InputDecoration(
-                  hintText: '例如: 2.5',
-                  border: OutlineInputBorder(),
-                ),
-                keyboardType: TextInputType.numberWithOptions(decimal: true),
-              ),
-            ],
-
-            SizedBox(height: 20),
-            Row(
-              children: [
-                Expanded(
-                  child: ElevatedButton(
-                    onPressed: _calculateDerivative,
-                    style: ElevatedButton.styleFrom(
-                      backgroundColor: Colors.blue,
-                      padding: EdgeInsets.symmetric(vertical: 12),
-                    ),
-                    child: Text('计算导数', style: TextStyle(color: Colors.white)),
-                  ),
-                ),
-                SizedBox(width: 10),
-                Expanded(
-                  child: ElevatedButton(
-                    onPressed: _clearAll,
-                    style: ElevatedButton.styleFrom(
-                      backgroundColor: Colors.grey,
-                      padding: EdgeInsets.symmetric(vertical: 12),
-                    ),
-                    child: Text('清空', style: TextStyle(color: Colors.white)),
-                  ),
-                ),
-              ],
-            ),
-
-            SizedBox(height: 20),
-            if (_result.isNotEmpty) ...[
-              Container(
-                width: double.infinity,
-                padding: EdgeInsets.all(16),
-                decoration: BoxDecoration(
-                  color: Colors.grey[50],
-                  borderRadius: BorderRadius.circular(8),
-                  border: Border.all(color: Colors.grey[300]!),
-                ),
-                child: Column(
-                  crossAxisAlignment: CrossAxisAlignment.start,
-                  children: [
-                    if (_derivative.isNotEmpty && _showPointInput) ...[
-                      Text(
-                        _derivative,
-                        style: TextStyle(fontSize: 14, color: Colors.grey[700]),
-                      ),
-                      SizedBox(height: 8),
-                    ],
-                    Text(
-                      _result,
-                      style: TextStyle(
-                        fontSize: 16,
-                        fontWeight: FontWeight.bold,
-                        color: Colors.blue[800],
-                      ),
-                    ),
-                  ],
-                ),
-              ),
-            ],
-
-            Spacer(),
-            _buildExamples(),
-          ],
-        ),
-      ),
-    );
-  }
-
-  Widget _buildExamples() {
-    return Container(
-      width: double.infinity,
-      padding: EdgeInsets.all(16),
-      decoration: BoxDecoration(
-        color: Colors.blue[50],
-        borderRadius: BorderRadius.circular(8),
-      ),
-      child: Column(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: [
-          Text(
-            '表达式示例:',
-            style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue[800]),
-          ),
-          SizedBox(height: 8),
-          _buildExampleItem('x^2 + 3*x + 2'),
-          _buildExampleItem('sin(x)'),
-          _buildExampleItem('cos(x)'),
-          _buildExampleItem('ln(x)'),
-          _buildExampleItem('sqrt(x)'),
-          _buildExampleItem('(x+1)/(x-1)'),
-        ],
-      ),
-    );
-  }
-
-  Widget _buildExampleItem(String example) {
-    return GestureDetector(
-      onTap: () {
-        setState(() {
-          _functionController.text = example;
-        });
-      },
-      child: Padding(
-        padding: EdgeInsets.symmetric(vertical: 4),
-        child: Text(
-          '• $example',
-          style: TextStyle(color: Colors.blue[700], fontSize: 14),
-        ),
-      ),
-    );
-  }
-
-  void _showInstructions() {
-    showDialog(
-      context: context,
-      builder: (context) => AlertDialog(
-        title: Text('使用说明'),
-        content: SingleChildScrollView(
-          child: Column(
-            crossAxisAlignment: CrossAxisAlignment.start,
-            mainAxisSize: MainAxisSize.min,
-            children: [
-              Text('1. 输入以x为自变量的函数表达式'),
-              SizedBox(height: 8),
-              Text('2. 支持的基本函数:'),
-              Text('   • sin(x), cos(x), tan(x)'),
-              Text('   • ln(x) - 自然对数'),
-              Text('   • sqrt(x) - 平方根'),
-              SizedBox(height: 8),
-              Text('3. 支持的操作符:'),
-              Text('   • +, -, *, /'),
-              Text('   • ^ - 幂运算'),
-              Text('   • () - 括号'),
-              SizedBox(height: 8),
-              Text('4. 可选计算某点处的导数值'),
-            ],
-          ),
-        ),
-        actions: [
-          TextButton(
-            onPressed: () => Navigator.pop(context),
-            child: Text('确定'),
-          ),
-        ],
-      ),
-    );
-  }
-
-  void _showExpressionHelp() {
-    showDialog(
-      context: context,
-      builder: (context) => AlertDialog(
-        title: Text('表达式格式帮助'),
-        content: SingleChildScrollView(
-          child: Column(
-            crossAxisAlignment: CrossAxisAlignment.start,
-            mainAxisSize: MainAxisSize.min,
-            children: [
-              Text('支持的数学表达式:'),
-              SizedBox(height: 8),
-              _buildHelpItem('x^2', 'x的平方'),
-              _buildHelpItem('3*x', '3乘以x'),
-              _buildHelpItem('sin(x)', '正弦函数'),
-              _buildHelpItem('cos(x)', '余弦函数'),
-              _buildHelpItem('ln(x)', '自然对数'),
-              _buildHelpItem('sqrt(x)', '平方根'),
-              _buildHelpItem('(x+1)/(x-1)', '分式'),
-            ],
-          ),
-        ),
-        actions: [
-          TextButton(
-            onPressed: () => Navigator.pop(context),
-            child: Text('确定'),
-          ),
-        ],
-      ),
-    );
-  }
-
-  Widget _buildHelpItem(String expression, String description) {
-    return Padding(
-      padding: EdgeInsets.symmetric(vertical: 4),
-      child: Row(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: [
-          Container(
-            width: 80,
-            child: Text(
-              expression,
-              style: TextStyle(fontFamily: 'Monospace', fontWeight: FontWeight.bold),
-            ),
-          ),
-          SizedBox(width: 10),
-          Expanded(child: Text(description)),
-        ],
-      ),
-    );
-  }
-}

+ 262 - 317
lib/tools/function_plotter.dart

@@ -1,103 +1,149 @@
 // tools/function_plotter.dart
 import 'package:flutter/material.dart';
-import 'package:math_expressions/math_expressions.dart';
-import 'package:fl_chart/fl_chart.dart';
+import 'package:provider/provider.dart';
 
+import '../curve_plot.dart';
+import '../expr_syntax.dart';
+import '../symja.dart';
+
+/// 函数图像绘制:把一个或多个 y = f(x) 交给 Symja 采样,再用项目自己的
+/// [CurvePlot] 画出来。
+///
+/// 为什么改成按钮触发的异步绘制:Symja 的求值都跑在原生侧的专用线程上,只能
+/// 异步拿结果;旧的 `math_expressions` 是同步的,才能每次 build 都重算一遍。
+/// 现在按下「绘制」采样一次,采样期间禁用按钮并显示忙碌状态。
 class FunctionPlotterPage extends StatefulWidget {
+  const FunctionPlotterPage({super.key});
+
   @override
-  _FunctionPlotterPageState createState() => _FunctionPlotterPageState();
+  State<FunctionPlotterPage> createState() => _FunctionPlotterPageState();
 }
 
 class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
-  final List<TextEditingController> _functionControllers = [
-    TextEditingController(text: 'x^2'),
-  ];
-  final TextEditingController _xMinController = TextEditingController(text: '-5');
-  final TextEditingController _xMaxController = TextEditingController(text: '5');
-  final TextEditingController _yMinController = TextEditingController(text: '-10');
-  final TextEditingController _yMaxController = TextEditingController(text: '10');
-  
-  List<Color> _functionColors = [
+  static const int _maxCurves = 5;
+
+  /// 每条曲线的固定颜色,下标与输入框一一对应(沿用旧版配色)。
+  static const List<Color> _curveColors = <Color>[
     Colors.blue,
     Colors.red,
     Colors.green,
     Colors.orange,
     Colors.purple,
   ];
-  
-  List<String> _functionErrors = [];
+
+  final List<TextEditingController> _exprs = <TextEditingController>[
+    TextEditingController(text: 'x^2'),
+  ];
+  final TextEditingController _xMin = TextEditingController(text: '-5');
+  final TextEditingController _xMax = TextEditingController(text: '5');
+  final TextEditingController _yMin = TextEditingController(text: '-10');
+  final TextEditingController _yMax = TextEditingController(text: '10');
+
+  List<CurveSeries> _series = const <CurveSeries>[];
+
+  /// 上一次绘制实际使用的 y 范围(没绘制过就是 null,交给 CurvePlot 自动定界)。
+  double? _plotYMin;
+  double? _plotYMax;
+
+  /// 每个输入框的错误信息,与 [_exprs] 下标对齐;null 表示该项正常。
+  List<String?> _errors = const <String?>[];
+
+  /// 坐标范围本身的错误(与某条曲线无关),显示在绘图区。
+  String? _rangeError;
+
+  bool _busy = false;
+
+  @override
+  void initState() {
+    super.initState();
+    // 首帧后再绘制:context.read 需要 element 已经挂载到 Provider 之下。
+    WidgetsBinding.instance.addPostFrameCallback((_) => _plot());
+  }
 
   @override
   void dispose() {
-    for (var controller in _functionControllers) {
-      controller.dispose();
+    for (final c in _exprs) {
+      c.dispose();
     }
-    _xMinController.dispose();
-    _xMaxController.dispose();
-    _yMinController.dispose();
-    _yMaxController.dispose();
+    _xMin.dispose();
+    _xMax.dispose();
+    _yMin.dispose();
+    _yMax.dispose();
     super.dispose();
   }
 
-  void _addFunction() {
-    if (_functionControllers.length < 5) {
-      setState(() {
-        _functionControllers.add(TextEditingController());
-      });
-    }
+  void _addCurve() {
+    if (_exprs.length >= _maxCurves) return;
+    setState(() => _exprs.add(TextEditingController()));
   }
 
-  void _removeFunction(int index) {
-    if (_functionControllers.length > 1) {
-      setState(() {
-        _functionControllers.removeAt(index);
-        if (_functionErrors.length > index) {
-          _functionErrors.removeAt(index);
-        }
-      });
-    }
+  void _removeCurve(int index) {
+    if (_exprs.length <= 1) return;
+    setState(() => _exprs.removeAt(index).dispose());
   }
 
-  List<FlSpot> _evaluateFunction(String function, double xMin, double xMax, int functionIndex) {
-    List<FlSpot> points = [];
-    
-    try {
-      Parser p = Parser();
-      Expression exp = p.parse(function);
-      ContextModel cm = ContextModel();
-      
-      int numberOfPoints = 200;
-      double step = (xMax - xMin) / numberOfPoints;
-      
-      for (int i = 0; i <= numberOfPoints; i++) {
-        double xValue = xMin + i * step;
-        
-        try {
-          cm.bindVariable(Variable('x'), Number(xValue));
-          double yValue = exp.evaluate(EvaluationType.REAL, cm);
-          
-          if (yValue.isFinite && yValue.abs() < 1e10) {
-            points.add(FlSpot(xValue, yValue));
-          } else {
-            points.add(FlSpot.nullSpot);
-          }
-        } catch (e) {
-          points.add(FlSpot.nullSpot);
-        }
-      }
-    } catch (e) {
-      if (_functionErrors.length <= functionIndex) {
-        _functionErrors.addAll(List.filled(functionIndex - _functionErrors.length + 1, ''));
+  Future<void> _plot() async {
+    if (_busy) return;
+
+    final xMin = double.tryParse(_xMin.text.trim());
+    final xMax = double.tryParse(_xMax.text.trim());
+    final yMin = double.tryParse(_yMin.text.trim());
+    final yMax = double.tryParse(_yMax.text.trim());
+
+    if (xMin == null || xMax == null || yMin == null || yMax == null) {
+      setState(() => _rangeError = '请输入有效的坐标范围');
+      return;
+    }
+    if (xMin >= xMax || yMin >= yMax) {
+      setState(() => _rangeError = '最小值必须小于最大值');
+      return;
+    }
+
+    final session = context.read<SymjaSession>();
+    setState(() {
+      _busy = true;
+      _rangeError = null;
+      _plotYMin = yMin;
+      _plotYMax = yMax;
+      // 每次重绘都重建,长度与输入框对齐,旧错误自然被清掉。
+      _errors = List<String?>.filled(_exprs.length, null);
+    });
+
+    final result = <CurveSeries>[];
+    // 逐条采样:一条失败不影响其它条,错误只挂到它自己那一栏。
+    for (var i = 0; i < _exprs.length; i++) {
+      final raw = _exprs[i].text.trim();
+      if (raw.isEmpty) continue;
+      try {
+        // 用户可能沿用旧的小写括号语法,先转成 Symja 语法再采样。
+        final expr = toSymjaSyntax(raw);
+        final s = await session.sample(
+          expr,
+          variable: 'x',
+          from: xMin,
+          to: xMax,
+          count: 240,
+        );
+        if (!mounted) return;
+        result.add(CurveSeries(
+          label: raw,
+          color: _curveColors[i],
+          // 非有限值(极点处的 ComplexInfinity 等)原样保留,
+          // CurvePlot 会把它们当成抬笔断点,这里不做过滤。
+          points: <Offset>[
+            for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]),
+          ],
+        ));
+      } on SymjaException catch (e) {
+        if (!mounted) return;
+        _errors[i] = '$e';
       }
-      _functionErrors[functionIndex] = '函数解析错误: ${e.toString()}';
     }
-    
-    return points;
-  }
 
-  void _plotFunctions() {
+    if (!mounted) return;
     setState(() {
-      _functionErrors.clear();
+      _series = result;
+      _busy = false;
     });
   }
 
@@ -105,30 +151,26 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
   Widget build(BuildContext context) {
     return Scaffold(
       appBar: AppBar(
-        title: Text('函数图像绘制', style: TextStyle(color: Colors.black)),
+        title: const Text('函数图像绘制', style: TextStyle(color: Colors.black)),
         backgroundColor: Colors.white,
         elevation: 1,
         actions: [
           IconButton(
-            icon: Icon(Icons.refresh),
-            onPressed: _plotFunctions,
+            icon: const Icon(Icons.refresh),
+            onPressed: _busy ? null : _plot,
             tooltip: '重新绘制',
           ),
         ],
       ),
-      body: SingleChildScrollView( // 添加外层滚动
+      body: SingleChildScrollView(
         child: Padding(
-          padding: EdgeInsets.all(16),
+          padding: const EdgeInsets.all(16),
           child: Column(
             children: [
-              // 函数输入区域 - 移除固定高度约束
-              _buildFunctionInputs(),
-              
-              SizedBox(height: 16), // 添加间距
-              
-              // 绘图区域 - 设置固定高度
-              Container(
-                height: MediaQuery.of(context).size.height * 0.5, // 使用屏幕高度的一半
+              _buildInputs(),
+              const SizedBox(height: 16),
+              SizedBox(
+                height: MediaQuery.of(context).size.height * 0.5,
                 child: _buildPlotArea(),
               ),
             ],
@@ -138,140 +180,168 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
     );
   }
 
-  Widget _buildFunctionInputs() {
+  Widget _buildInputs() {
     return Card(
       elevation: 4,
       child: Padding(
-        padding: EdgeInsets.all(16),
+        padding: const EdgeInsets.all(16),
         child: Column(
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
-            Text(
+            const Text(
               '函数表达式 (使用 x 作为变量)',
               style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
             ),
-            SizedBox(height: 10),
-            Text(
-              '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, e',
+            const SizedBox(height: 10),
+            const Text(
+              '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, pi, e',
               style: TextStyle(fontSize: 12, color: Colors.grey),
             ),
-            SizedBox(height: 10),
-            
-            // 坐标范围设置 - 在小屏幕上改为垂直布局
+            const SizedBox(height: 10),
             LayoutBuilder(
               builder: (context, constraints) {
+                final fields = <Widget>[
+                  _buildRangeField(_xMin, 'x最小值'),
+                  _buildRangeField(_xMax, 'x最大值'),
+                  _buildRangeField(_yMin, 'y最小值'),
+                  _buildRangeField(_yMax, 'y最大值'),
+                ];
+                // 窄屏竖向排,宽屏横向排。
                 if (constraints.maxWidth < 600) {
-                  // 小屏幕垂直布局
                   return Column(
                     children: [
-                      _buildRangeTextField(_xMinController, 'x最小值'),
-                      SizedBox(height: 10),
-                      _buildRangeTextField(_xMaxController, 'x最大值'),
-                      SizedBox(height: 10),
-                      _buildRangeTextField(_yMinController, 'y最小值'),
-                      SizedBox(height: 10),
-                      _buildRangeTextField(_yMaxController, 'y最大值'),
-                    ],
-                  );
-                } else {
-                  // 大屏幕水平布局
-                  return Row(
-                    children: [
-                      Expanded(child: _buildRangeTextField(_xMinController, 'x最小值')),
-                      SizedBox(width: 10),
-                      Expanded(child: _buildRangeTextField(_xMaxController, 'x最大值')),
-                      SizedBox(width: 10),
-                      Expanded(child: _buildRangeTextField(_yMinController, 'y最小值')),
-                      SizedBox(width: 10),
-                      Expanded(child: _buildRangeTextField(_yMaxController, 'y最大值')),
+                      for (var i = 0; i < fields.length; i++) ...[
+                        if (i > 0) const SizedBox(height: 10),
+                        fields[i],
+                      ],
                     ],
                   );
                 }
+                return Row(
+                  children: [
+                    for (var i = 0; i < fields.length; i++) ...[
+                      if (i > 0) const SizedBox(width: 10),
+                      Expanded(child: fields[i]),
+                    ],
+                  ],
+                );
               },
             ),
-            SizedBox(height: 10),
-            
-            // 函数输入列表 - 设置最大高度并允许滚动
+            const SizedBox(height: 4),
+            const Text(
+              '注: x 范围决定采样区间,y 范围决定显示窗口;图上还能双指缩放、拖动、双击复位。',
+              style: TextStyle(fontSize: 11, color: Colors.grey),
+            ),
+            const SizedBox(height: 10),
             Container(
-              constraints: BoxConstraints(
-                maxHeight: 200, // 限制最大高度
-              ),
+              constraints: const BoxConstraints(maxHeight: 240),
               child: ListView.builder(
                 shrinkWrap: true,
-                itemCount: _functionControllers.length,
-                itemBuilder: (context, index) {
-                  return Padding(
-                    padding: EdgeInsets.symmetric(vertical: 5),
-                    child: Row(
-                      children: [
-                        Container(
-                          width: 20,
-                          height: 20,
-                          decoration: BoxDecoration(
-                            color: _functionColors[index],
-                            shape: BoxShape.circle,
-                          ),
-                        ),
-                        SizedBox(width: 10),
-                        Expanded(
-                          child: TextField(
-                            controller: _functionControllers[index],
-                            decoration: InputDecoration(
-                              hintText: '例如: x^2, sin(x), ln(x)',
-                              border: OutlineInputBorder(),
-                              errorText: _functionErrors.length > index ? _functionErrors[index] : null,
-                            ),
-                            onChanged: (value) => _plotFunctions(),
-                          ),
-                        ),
-                        SizedBox(width: 10),
-                        if (_functionControllers.length > 1)
-                          IconButton(
-                            icon: Icon(Icons.remove, color: Colors.red),
-                            onPressed: () => _removeFunction(index),
-                          ),
-                      ],
-                    ),
-                  );
-                },
+                itemCount: _exprs.length,
+                itemBuilder: _buildExprRow,
               ),
             ),
-            
-            SizedBox(height: 10),
-            
-            // 添加函数按钮
-            if (_functionControllers.length < 5)
-              Center(
-                child: ElevatedButton.icon(
-                  onPressed: () {
-                    _addFunction();
-                    _plotFunctions();
-                  },
-                  icon: Icon(Icons.add),
-                  label: Text('添加函数'),
+            const SizedBox(height: 10),
+            Row(
+              children: [
+                if (_exprs.length < _maxCurves)
+                  OutlinedButton.icon(
+                    onPressed: _busy ? null : _addCurve,
+                    icon: const Icon(Icons.add),
+                    label: const Text('添加函数'),
+                  ),
+                const Spacer(),
+                ElevatedButton.icon(
+                  onPressed: _busy ? null : _plot,
+                  icon: _busy
+                      ? const SizedBox(
+                          width: 16,
+                          height: 16,
+                          child: CircularProgressIndicator(strokeWidth: 2),
+                        )
+                      : const Icon(Icons.show_chart),
+                  label: Text(_busy ? '绘制中…' : '绘制图像'),
                   style: ElevatedButton.styleFrom(
                     backgroundColor: Colors.blue,
                     foregroundColor: Colors.white,
                   ),
                 ),
-              ),
+              ],
+            ),
           ],
         ),
       ),
     );
   }
 
-  // 辅助方法:构建范围输入框
-  Widget _buildRangeTextField(TextEditingController controller, String label) {
+  Widget _buildExprRow(BuildContext context, int index) {
+    final raw = _exprs[index].text.trim();
+    // 只有在语法真的被改写时才提示,避免对已经正确的输入刷屏。
+    final rewrite = raw.isEmpty ? null : symjaRewriteOf(raw);
+    return Padding(
+      padding: const EdgeInsets.symmetric(vertical: 5),
+      child: Row(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Container(
+            width: 20,
+            height: 20,
+            margin: const EdgeInsets.only(top: 16),
+            decoration: BoxDecoration(
+              color: _curveColors[index],
+              shape: BoxShape.circle,
+            ),
+          ),
+          const SizedBox(width: 10),
+          Expanded(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                TextField(
+                  controller: _exprs[index],
+                  // 输入时重建,让下面的语法转换提示实时更新。
+                  onChanged: (_) => setState(() {}),
+                  decoration: InputDecoration(
+                    hintText: '例如: x^2, sin(x), Sin[x]',
+                    border: const OutlineInputBorder(),
+                    errorText: index < _errors.length ? _errors[index] : null,
+                  ),
+                ),
+                if (rewrite != null)
+                  Padding(
+                    padding: const EdgeInsets.only(top: 4, left: 4),
+                    child: Text(
+                      '已按 Symja 语法解释为: $rewrite',
+                      style: TextStyle(fontSize: 11, color: Colors.grey[600]),
+                    ),
+                  ),
+              ],
+            ),
+          ),
+          const SizedBox(width: 10),
+          if (_exprs.length > 1)
+            IconButton(
+              icon: const Icon(Icons.remove, color: Colors.red),
+              tooltip: '删除该函数',
+              onPressed: () => _removeCurve(index),
+            ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildRangeField(TextEditingController controller, String label) {
     return TextField(
       controller: controller,
       decoration: InputDecoration(
         labelText: label,
-        border: OutlineInputBorder(),
-        contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+        border: const OutlineInputBorder(),
+        contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+      ),
+      keyboardType: TextInputType.numberWithOptions(
+        signed: true,
+        decimal: true,
       ),
-      keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
-      onChanged: (value) => _plotFunctions(),
     );
   }
 
@@ -279,17 +349,15 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
     return Card(
       elevation: 4,
       child: Padding(
-        padding: EdgeInsets.all(16),
+        padding: const EdgeInsets.all(16),
         child: Column(
           children: [
-            Text(
+            const Text(
               '函数图像',
               style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
             ),
-            SizedBox(height: 10),
-            Expanded(
-              child: _buildPlot(),
-            ),
+            const SizedBox(height: 10),
+            Expanded(child: _buildPlot()),
           ],
         ),
       ),
@@ -297,153 +365,30 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
   }
 
   Widget _buildPlot() {
-    double xMin, xMax, yMin, yMax;
-    
-    try {
-      xMin = double.parse(_xMinController.text);
-      xMax = double.parse(_xMaxController.text);
-      yMin = double.parse(_yMinController.text);
-      yMax = double.parse(_yMaxController.text);
-    } catch (e) {
-      return Center(
-        child: Text('请输入有效的坐标范围', style: TextStyle(color: Colors.red)),
-      );
+    if (_busy) {
+      return const Center(child: CircularProgressIndicator());
     }
-    
-    if (xMin >= xMax || yMin >= yMax) {
+    if (_rangeError != null) {
       return Center(
-        child: Text('最小值必须小于最大值', style: TextStyle(color: Colors.red)),
+        child: Text(_rangeError!, style: const TextStyle(color: Colors.red)),
       );
     }
-    
-    List<LineChartBarData> lineBars = [];
-    
-    for (int i = 0; i < _functionControllers.length; i++) {
-      String function = _functionControllers[i].text.trim();
-      if (function.isEmpty) continue;
-      
-      List<FlSpot> spots = _evaluateFunction(function, xMin, xMax, i);
-      
-      if (spots.isNotEmpty) {
-        lineBars.add(
-          LineChartBarData(
-            spots: spots,
-            isCurved: true,
-            color: _functionColors[i],
-            barWidth: 2,
-            isStrokeCapRound: true,
-            dotData: FlDotData(show: false),
-            belowBarData: BarAreaData(show: false),
-          ),
-        );
-      }
-    }
-    
-    if (lineBars.isEmpty) {
+    if (_series.isEmpty) {
       return Center(
         child: Column(
           mainAxisAlignment: MainAxisAlignment.center,
-          children: [
+          children: const [
             Icon(Icons.functions, size: 50, color: Colors.grey),
             SizedBox(height: 10),
             Text('暂无函数图像可显示', style: TextStyle(color: Colors.grey)),
-            Text('请输入有效的函数表达式', style: TextStyle(color: Colors.grey, fontSize: 12)),
+            Text(
+              '请输入函数表达式后点击「绘制图像」',
+              style: TextStyle(color: Colors.grey, fontSize: 12),
+            ),
           ],
         ),
       );
     }
-    
-    return LineChart(
-      LineChartData(
-        clipData: FlClipData.all(),
-        gridData: FlGridData(
-          show: true,
-          drawVerticalLine: true,
-          drawHorizontalLine: true,
-          getDrawingHorizontalLine: (value) {
-            return FlLine(
-              color: Colors.grey.withOpacity(0.3),
-              strokeWidth: 1,
-            );
-          },
-          getDrawingVerticalLine: (value) {
-            return FlLine(
-              color: Colors.grey.withOpacity(0.3),
-              strokeWidth: 1,
-            );
-          },
-        ),
-        titlesData: FlTitlesData(
-          show: true,
-          bottomTitles: AxisTitles(
-            sideTitles: SideTitles(
-              showTitles: true,
-              reservedSize: 30,
-              interval: _calculateInterval(xMin, xMax),
-              getTitlesWidget: (value, meta) {
-                return Text(
-                  value.toStringAsFixed(1),
-                  style: TextStyle(fontSize: 10, color: Colors.black),
-                );
-              },
-            ),
-          ),
-          leftTitles: AxisTitles(
-            sideTitles: SideTitles(
-              showTitles: true,
-              reservedSize: 40,
-              interval: _calculateInterval(yMin, yMax),
-              getTitlesWidget: (value, meta) {
-                return Text(
-                  value.toStringAsFixed(1),
-                  style: TextStyle(fontSize: 10, color: Colors.black),
-                );
-              },
-            ),
-          ),
-          rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
-          topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
-        ),
-        borderData: FlBorderData(
-          show: true,
-          border: Border.all(color: Colors.black, width: 2),
-        ),
-        minX: xMin,
-        maxX: xMax,
-        minY: yMin,
-        maxY: yMax,
-        lineBarsData: lineBars,
-        lineTouchData: LineTouchData(enabled: false),
-        extraLinesData: ExtraLinesData(
-          horizontalLines: [
-            HorizontalLine(
-              y: 0,
-              color: Colors.black,
-              strokeWidth: 1,
-            ),
-          ],
-          verticalLines: [
-            VerticalLine(
-              x: 0,
-              color: Colors.black,
-              strokeWidth: 1,
-            ),
-          ],
-        ),
-      ),
-    );
+    return CurvePlot(series: _series, yMin: _plotYMin, yMax: _plotYMax);
   }
-
-  double _calculateInterval(double min, double max) {
-    double range = max - min;
-    if (range <= 0) return 1.0;
-    
-    if (range <= 2) return 0.5;
-    if (range <= 5) return 1.0;
-    if (range <= 10) return 2.0;
-    if (range <= 20) return 5.0;
-    if (range <= 50) return 10.0;
-    if (range <= 100) return 20.0;
-    return 50.0;
-  }
-}
+}

+ 262 - 303
lib/tools/parametric_plotter.dart

@@ -1,227 +1,216 @@
 // tools/parametric_plotter.dart
 import 'package:flutter/material.dart';
-import 'package:math_expressions/math_expressions.dart';
-import 'package:fl_chart/fl_chart.dart';
+import 'package:provider/provider.dart';
 
+import '../curve_plot.dart';
+import '../expr_syntax.dart';
+import '../symja.dart';
+
+/// 参数方程绘图:x = f(t), y = g(t),用 Symja 采样后交给 [CurvePlot] 画。
+///
+/// 采样要点:Symja 的 `sample` 一次只能算一个表达式,所以 x(t) 和 y(t) 各调用
+/// 一次,参数区间和点数完全一致,再把两个结果按下标配成 (x, y) 点。
 class ParametricPlotterPage extends StatefulWidget {
+  const ParametricPlotterPage({super.key});
+
   @override
-  _ParametricPlotterPageState createState() => _ParametricPlotterPageState();
+  State<ParametricPlotterPage> createState() => _ParametricPlotterPageState();
 }
 
 class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
-  final TextEditingController xController = TextEditingController(text: 'cos(t)');
-  final TextEditingController yController = TextEditingController(text: 'sin(t)');
-  final TextEditingController tMinController = TextEditingController(text: '0');
-  final TextEditingController tMaxController = TextEditingController(text: '6.3');
-  final TextEditingController stepController = TextEditingController(text: '0.1');
+  final TextEditingController _xCtrl = TextEditingController(text: 'cos(t)');
+  final TextEditingController _yCtrl = TextEditingController(text: 'sin(t)');
+  final TextEditingController _tMinCtrl = TextEditingController(text: '0');
+  final TextEditingController _tMaxCtrl = TextEditingController(text: '6.3');
+  final TextEditingController _stepCtrl = TextEditingController(text: '0.1');
 
-  List<FlSpot> points = [];
-  String errorMessage = '';
-  bool isLoading = false;
-
-  double minX = 0;
-  double maxX = 0;
-  double minY = 0;
-  double maxY = 0;
+  List<CurveSeries> _series = const <CurveSeries>[];
+  String? _error;
+  bool _busy = false;
 
   @override
   void initState() {
     super.initState();
-    WidgetsBinding.instance.addPostFrameCallback((_) {
-      _plotGraph();
-    });
+    // 首帧后再绘制:context.read 需要 element 已经挂载到 Provider 之下。
+    WidgetsBinding.instance.addPostFrameCallback((_) => _plot());
   }
 
-  void _plotGraph() {
-    setState(() {
-      isLoading = true;
-      errorMessage = '';
-    });
-
-    try {
-      final tMin = double.parse(tMinController.text);
-      final tMax = double.parse(tMaxController.text);
-      final step = double.parse(stepController.text);
+  @override
+  void dispose() {
+    _xCtrl.dispose();
+    _yCtrl.dispose();
+    _tMinCtrl.dispose();
+    _tMaxCtrl.dispose();
+    _stepCtrl.dispose();
+    super.dispose();
+  }
 
-      if (tMin >= tMax) {
-        throw Exception('t最小值必须小于t最大值');
-      }
-      if (step <= 0) {
-        throw Exception('步长必须大于0');
-      }
+  Future<void> _plot() async {
+    if (_busy) return;
 
-      final xExpr = _parseExpression(xController.text);
-      final yExpr = _parseExpression(yController.text);
+    final xRaw = _xCtrl.text.trim();
+    final yRaw = _yCtrl.text.trim();
+    if (xRaw.isEmpty || yRaw.isEmpty) {
+      setState(() => _error = '请填写 x(t) 和 y(t) 表达式');
+      return;
+    }
 
-      List<FlSpot> newPoints = [];
-      double currentMinX = double.infinity;
-      double currentMaxX = -double.infinity;
-      double currentMinY = double.infinity;
-      double currentMaxY = -double.infinity;
+    final tMin = double.tryParse(_tMinCtrl.text.trim());
+    final tMax = double.tryParse(_tMaxCtrl.text.trim());
+    final step = double.tryParse(_stepCtrl.text.trim());
+    if (tMin == null || tMax == null || step == null) {
+      setState(() => _error = '请输入有效的 t 范围与步长');
+      return;
+    }
+    if (tMin >= tMax) {
+      setState(() => _error = 't最小值必须小于t最大值');
+      return;
+    }
+    if (step <= 0) {
+      setState(() => _error = '步长必须大于0');
+      return;
+    }
 
-      for (double t = tMin; t <= tMax; t += step) {
-        try {
-          final x = _evaluateExpression(xExpr, t);
-          final y = _evaluateExpression(yExpr, t);
+    // Symja 的 sample 只接受「点数」,这里把用户给的步长换算成点数,
+    // 保留步长输入的含义;再做上下限保护,避免点数过多拖垮求值。
+    final count = ((tMax - tMin) / step).round() + 1;
+    final n = count.clamp(2, 5000).toInt();
 
-          if (x.isFinite && y.isFinite) {
-            newPoints.add(FlSpot(x, y));
-            currentMinX = x < currentMinX ? x : currentMinX;
-            currentMaxX = x > currentMaxX ? x : currentMaxX;
-            currentMinY = y < currentMinY ? y : currentMinY;
-            currentMaxY = y > currentMaxY ? y : currentMaxY;
-          }
-        } catch (e) {
-          // 跳过无效点
-        }
-      }
+    final session = context.read<SymjaSession>();
+    setState(() {
+      _busy = true;
+      _error = null;
+    });
 
-      if (newPoints.isEmpty) {
-        throw Exception('未生成有效数据点,请检查参数方程');
-      }
+    List<CurveSeries> series = const <CurveSeries>[];
+    try {
+      // 同一个 t 区间、同一点数分别采样两条曲线——Symja 一次只能算一个表达式。
+      final xs = await session.sample(
+        toSymjaSyntax(xRaw),
+        variable: 't',
+        from: tMin,
+        to: tMax,
+        count: n,
+      );
+      final ys = await session.sample(
+        toSymjaSyntax(yRaw),
+        variable: 't',
+        from: tMin,
+        to: tMax,
+        count: n,
+      );
+      if (!mounted) return;
 
+      final m = xs.ys.length < ys.ys.length ? xs.ys.length : ys.ys.length;
+      series = <CurveSeries>[
+        CurveSeries(
+          label: 'x=$xRaw, y=$yRaw',
+          color: Colors.blue,
+          // NaN(极点/无定义点)照传,CurvePlot 会当成断点抬笔。
+          points: <Offset>[
+            for (var i = 0; i < m; i++) Offset(xs.ys[i], ys.ys[i]),
+          ],
+        ),
+      ];
+    } on SymjaException catch (e) {
+      if (!mounted) return;
       setState(() {
-        points = newPoints;
-        minX = currentMinX;
-        maxX = currentMaxX;
-        minY = currentMinY;
-        maxY = currentMaxY;
-      });
-    } catch (e) {
-      setState(() {
-        errorMessage = e.toString();
-        points = [];
-      });
-    } finally {
-      setState(() {
-        isLoading = false;
+        _error = '$e';
+        _busy = false;
       });
+      return;
     }
-  }
 
-  Expression _parseExpression(String expression) {
-  // 清理表达式中的空格
-  String cleaned = expression.replaceAll(' ', '');
-  
-  // 将常见函数名转换为math_expressions识别的格式
-  cleaned = cleaned.replaceAllMapped(RegExp(r'(\d)([a-zA-Z])'), (match) {
-    return '${match.group(1)}*${match.group(2)}';
-  });
-  
-  // 移除这行,因为math_expressions使用^作为幂运算符
-  // cleaned = cleaned.replaceAll('^', '**');
-  
-  try {
-    Parser p = Parser();
-    return p.parse(cleaned);
-  } catch (e) {
-    throw Exception('表达式解析错误: $e');
+    if (!mounted) return;
+    setState(() {
+      _series = series;
+      _busy = false;
+    });
   }
-}
 
-  double _evaluateExpression(Expression expr, double t) {
-    ContextModel cm = ContextModel()
-      ..bindVariable(Variable('t'), Number(t))
-      ..bindVariable(Variable('pi'), Number(3.141592653589793))
-      ..bindVariable(Variable('e'), Number(2.718281828459045));
+  void _applyPreset(String xExpr, String yExpr, String tMin, String tMax) {
+    setState(() {
+      _xCtrl.text = xExpr;
+      _yCtrl.text = yExpr;
+      _tMinCtrl.text = tMin;
+      _tMaxCtrl.text = tMax;
+      _stepCtrl.text = '0.1';
+    });
+    _plot();
+  }
 
-    try {
-      return expr.evaluate(EvaluationType.REAL, cm);
-    } catch (e) {
-      throw Exception('在 t=$t 时计算错误: $e');
-    }
+  @override
+  Widget build(BuildContext context) {
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('参数方程绘图', style: TextStyle(color: Colors.black)),
+        backgroundColor: Colors.white,
+        elevation: 1,
+      ),
+      body: SingleChildScrollView(
+        child: Column(
+          children: [
+            _buildInputPanel(),
+            _buildGraph(),
+            _buildExamples(),
+          ],
+        ),
+      ),
+    );
   }
 
   Widget _buildInputPanel() {
     return Card(
       elevation: 4,
-      margin: EdgeInsets.all(16),
+      margin: const EdgeInsets.all(16),
       child: Padding(
-        padding: EdgeInsets.all(16),
+        padding: const EdgeInsets.all(16),
         child: Column(
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
-            Text(
+            const Text(
               '参数方程设置',
               style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
             ),
-            SizedBox(height: 16),
-            TextField(
-              controller: xController,
-              decoration: InputDecoration(
-                labelText: 'x = f(t)',
-                hintText: '例如: cos(t), t^2, 2*t+1',
-                border: OutlineInputBorder(),
-              ),
-            ),
-            SizedBox(height: 12),
-            TextField(
-              controller: yController,
-              decoration: InputDecoration(
-                labelText: 'y = g(t)',
-                hintText: '例如: sin(t), t^3, 3*t-2',
-                border: OutlineInputBorder(),
-              ),
-            ),
-            SizedBox(height: 16),
+            const SizedBox(height: 16),
+            _buildExprField(_xCtrl, 'x = f(t)', '例如: cos(t), t^2, Cos[t]'),
+            const SizedBox(height: 12),
+            _buildExprField(_yCtrl, 'y = g(t)', '例如: sin(t), t^3, Sin[t]'),
+            const SizedBox(height: 16),
             Row(
               children: [
-                Expanded(
-                  child: TextField(
-                    controller: tMinController,
-                    decoration: InputDecoration(
-                      labelText: 't最小值',
-                      border: OutlineInputBorder(),
-                    ),
-                    keyboardType: TextInputType.numberWithOptions(decimal: true),
-                  ),
-                ),
-                SizedBox(width: 12),
-                Expanded(
-                  child: TextField(
-                    controller: tMaxController,
-                    decoration: InputDecoration(
-                      labelText: 't最大值',
-                      border: OutlineInputBorder(),
-                    ),
-                    keyboardType: TextInputType.numberWithOptions(decimal: true),
-                  ),
-                ),
-                SizedBox(width: 12),
-                Expanded(
-                  child: TextField(
-                    controller: stepController,
-                    decoration: InputDecoration(
-                      labelText: '步长',
-                      border: OutlineInputBorder(),
-                    ),
-                    keyboardType: TextInputType.numberWithOptions(decimal: true),
-                  ),
-                ),
+                Expanded(child: _buildNumberField(_tMinCtrl, 't最小值')),
+                const SizedBox(width: 12),
+                Expanded(child: _buildNumberField(_tMaxCtrl, 't最大值')),
+                const SizedBox(width: 12),
+                Expanded(child: _buildNumberField(_stepCtrl, '步长')),
               ],
             ),
-            SizedBox(height: 16),
-            if (errorMessage.isNotEmpty)
+            const SizedBox(height: 12),
+            if (_error != null)
               Container(
                 width: double.infinity,
-                padding: EdgeInsets.all(12),
+                padding: const EdgeInsets.all(12),
                 decoration: BoxDecoration(
                   color: Colors.red[50],
                   borderRadius: BorderRadius.circular(8),
                   border: Border.all(color: Colors.red),
                 ),
-                child: Text(
-                  errorMessage,
-                  style: TextStyle(color: Colors.red),
-                ),
+                child: Text(_error!, style: const TextStyle(color: Colors.red)),
               ),
-            SizedBox(height: 16),
+            const SizedBox(height: 16),
             ElevatedButton.icon(
-              onPressed: _plotGraph,
-              icon: Icon(Icons.show_chart),
-              label: Text('绘制图像'),
+              onPressed: _busy ? null : _plot,
+              icon: _busy
+                  ? const SizedBox(
+                      width: 16,
+                      height: 16,
+                      child: CircularProgressIndicator(strokeWidth: 2),
+                    )
+                  : const Icon(Icons.show_chart),
+              label: Text(_busy ? '绘制中…' : '绘制图像'),
               style: ElevatedButton.styleFrom(
-                minimumSize: Size(double.infinity, 50),
+                minimumSize: const Size(double.infinity, 50),
               ),
             ),
           ],
@@ -230,157 +219,130 @@ class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
     );
   }
 
- Widget _buildGraph() {
-  if (points.isEmpty) {
-    return Container(
-      height: 300,
-      child: Center(
-        child: Text(
-          '暂无数据',
-          style: TextStyle(fontSize: 16, color: Colors.grey),
+  /// 表达式输入框 + 语法转换提示(只有真的改写了才显示)。
+  Widget _buildExprField(
+    TextEditingController controller,
+    String label,
+    String hint,
+  ) {
+    final raw = controller.text.trim();
+    final rewrite = raw.isEmpty ? null : symjaRewriteOf(raw);
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        TextField(
+          controller: controller,
+          // 输入时重建,让下面的语法转换提示实时更新。
+          onChanged: (_) => setState(() {}),
+          decoration: InputDecoration(
+            labelText: label,
+            hintText: hint,
+            border: const OutlineInputBorder(),
+          ),
         ),
-      ),
+        if (rewrite != null)
+          Padding(
+            padding: const EdgeInsets.only(top: 4, left: 4),
+            child: Text(
+              '已按 Symja 语法解释为: $rewrite',
+              style: TextStyle(fontSize: 11, color: Colors.grey[600]),
+            ),
+          ),
+      ],
     );
   }
 
-  // 计算坐标轴范围,确保x和y轴有相同的缩放比例
-  double rangeX = maxX - minX;
-  double rangeY = maxY - minY;
-  double maxRange = rangeX > rangeY ? rangeX : rangeY;
-  
-  double centerX = (minX + maxX) / 2;
-  double centerY = (minY + maxY) / 2;
-  
-  double adjustedMinX = centerX - maxRange / 2;
-  double adjustedMaxX = centerX + maxRange / 2;
-  double adjustedMinY = centerY - maxRange / 2;
-  double adjustedMaxY = centerY + maxRange / 2;
-
-  // 添加一些边距
-  double margin = maxRange * 0.1;
-  adjustedMinX -= margin;
-  adjustedMaxX += margin;
-  adjustedMinY -= margin;
-  adjustedMaxY += margin;
+  Widget _buildNumberField(TextEditingController controller, String label) {
+    return TextField(
+      controller: controller,
+      decoration: InputDecoration(
+        labelText: label,
+        border: const OutlineInputBorder(),
+      ),
+      keyboardType: TextInputType.numberWithOptions(
+        signed: true,
+        decimal: true,
+      ),
+    );
+  }
 
-  return Card(
-    elevation: 4,
-    margin: EdgeInsets.all(16),
-    child: Padding(
-      padding: EdgeInsets.all(16),
-      child: Column(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: [
-          Text(
-            '参数方程图像',
-            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-          ),
-          SizedBox(height: 16),
-          // 修复:使用 AspectRatio 确保图表始终保持正方形
-          AspectRatio(
-            aspectRatio: 1, // 强制宽高比为1:1
-            child: Container(
-              decoration: BoxDecoration(
-                border: Border.all(color: Colors.grey[300]!),
-                borderRadius: BorderRadius.circular(8),
-              ),
-              child: LineChart(
-                LineChartData(
-                  gridData: FlGridData(show: true),
-                  titlesData: FlTitlesData(
-                    show: true,
-                    bottomTitles: AxisTitles(
-                      sideTitles: SideTitles(showTitles: true),
-                    ),
-                    leftTitles: AxisTitles(
-                      sideTitles: SideTitles(showTitles: true),
-                    ),
-                    rightTitles: AxisTitles(
-                      sideTitles: SideTitles(showTitles: false),
-                    ),
-                    topTitles: AxisTitles(
-                      sideTitles: SideTitles(showTitles: false),
-                    ),
-                  ),
-                  borderData: FlBorderData(show: true),
-                  minX: adjustedMinX,
-                  maxX: adjustedMaxX,
-                  minY: adjustedMinY,
-                  maxY: adjustedMaxY,
-                  lineTouchData: LineTouchData(enabled: false),
-                  lineBarsData: [
-                    LineChartBarData(
-                      spots: points,
-                      isCurved: true,
-                      color: Colors.blue,
-                      barWidth: 3,
-                      dotData: FlDotData(show: false),
-                      belowBarData: BarAreaData(show: false),
-                    ),
-                  ],
+  Widget _buildGraph() {
+    return Card(
+      elevation: 4,
+      margin: const EdgeInsets.all(16),
+      child: Padding(
+        padding: const EdgeInsets.all(16),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            const Text(
+              '参数方程图像',
+              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
+            ),
+            const SizedBox(height: 16),
+            // 用 AspectRatio 强制正方形,参数曲线的 x/y 比例才不会被拉伸。
+            AspectRatio(
+              aspectRatio: 1,
+              child: Container(
+                decoration: BoxDecoration(
+                  border: Border.all(color: Colors.grey[300]!),
+                  borderRadius: BorderRadius.circular(8),
                 ),
+                child: _busy
+                    ? const Center(child: CircularProgressIndicator())
+                    : (_series.isEmpty
+                        ? const Center(
+                            child: Text(
+                              '暂无数据',
+                              style: TextStyle(fontSize: 16, color: Colors.grey),
+                            ),
+                          )
+                        : CurvePlot(series: _series)),
               ),
             ),
-          ),
-          SizedBox(height: 16),
-          Text(
-            '方程: x = ${xController.text}, y = ${yController.text}',
-            style: TextStyle(fontSize: 14, color: Colors.grey[600]),
-          ),
-          Text(
-            '参数范围: t ∈ [${tMinController.text}, ${tMaxController.text}], 步长: ${stepController.text}',
-            style: TextStyle(fontSize: 14, color: Colors.grey[600]),
-          ),
-        ],
-      ),
-    ),
-  );
-}
-  @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(
-        title: Text('参数方程绘图', style: TextStyle(color: Colors.black)),
-        backgroundColor: Colors.white,
-        elevation: 1,
-      ),
-      body: isLoading
-          ? Center(child: CircularProgressIndicator())
-          : SingleChildScrollView(
-              child: Column(
-                children: [
-                  _buildInputPanel(),
-                  _buildGraph(),
-                  _buildExamples(),
-                ],
-              ),
+            const SizedBox(height: 16),
+            Text(
+              '方程: x = ${_xCtrl.text}, y = ${_yCtrl.text}',
+              style: TextStyle(fontSize: 14, color: Colors.grey[600]),
+            ),
+            Text(
+              '参数范围: t ∈ [${_tMinCtrl.text}, ${_tMaxCtrl.text}], 步长: ${_stepCtrl.text}',
+              style: TextStyle(fontSize: 14, color: Colors.grey[600]),
             ),
+          ],
+        ),
+      ),
     );
   }
 
   Widget _buildExamples() {
     return Card(
       elevation: 4,
-      margin: EdgeInsets.all(16),
+      margin: const EdgeInsets.all(16),
       child: Padding(
-        padding: EdgeInsets.all(16),
+        padding: const EdgeInsets.all(16),
         child: Column(
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
-            Text(
+            const Text(
               '示例方程',
               style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
             ),
-            SizedBox(height: 12),
+            const SizedBox(height: 12),
             Wrap(
               spacing: 8,
               runSpacing: 8,
               children: [
-                // 删除了圆的示例
                 _buildExampleChip('椭圆', '2*cos(t)', 'sin(t)', '0', '6.3'),
                 _buildExampleChip('螺旋线', 't*cos(t)', 't*sin(t)', '0', '12.6'),
                 _buildExampleChip('抛物线', 't', 't^2', '-2', '2'),
-                _buildExampleChip('心形线', '2*cos(t)-cos(2*t)', '2*sin(t)-sin(2*t)', '0', '6.3'),
+                _buildExampleChip(
+                  '心形线',
+                  '2*cos(t)-cos(2*t)',
+                  '2*sin(t)-sin(2*t)',
+                  '0',
+                  '6.3',
+                ),
               ],
             ),
           ],
@@ -389,20 +351,17 @@ class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
     );
   }
 
-  Widget _buildExampleChip(String title, String xExpr, String yExpr, String tMin, String tMax) {
+  Widget _buildExampleChip(
+    String title,
+    String xExpr,
+    String yExpr,
+    String tMin,
+    String tMax,
+  ) {
     return ActionChip(
       label: Text(title),
-      onPressed: () {
-        setState(() {
-          xController.text = xExpr;
-          yController.text = yExpr;
-          tMinController.text = tMin;
-          tMaxController.text = tMax;
-          stepController.text = '0.1';
-        });
-        _plotGraph();
-      },
+      onPressed: () => _applyPreset(xExpr, yExpr, tMin, tMax),
       backgroundColor: Colors.blue[50],
     );
   }
-}
+}

+ 98 - 0
test/expr_syntax_test.dart

@@ -0,0 +1,98 @@
+// 语法兼容层的测试。这个转换是"悄悄改错也看不出来"的那类东西,
+// 所以边界情况都钉在测试里。
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:cal/expr_syntax.dart';
+
+void main() {
+  group('函数调用', () {
+    test('小写括号写法转成 Symja 方括号', () {
+      expect(toSymjaSyntax('sin(x)'), 'Sin[x]');
+      expect(toSymjaSyntax('ln(x)'), 'Log[x]');
+      expect(toSymjaSyntax('sqrt(x)'), 'Sqrt[x]');
+      expect(toSymjaSyntax('abs(x)'), 'Abs[x]');
+    });
+
+    test('函数名大小写不敏感', () {
+      expect(toSymjaSyntax('SIN(x)'), 'Sin[x]');
+      expect(toSymjaSyntax('Sin(x)'), 'Sin[x]');
+    });
+
+    test('嵌套调用', () {
+      expect(toSymjaSyntax('sin(cos(x))'), 'Sin[Cos[x]]');
+      expect(toSymjaSyntax('sqrt(1+x^2)'), 'Sqrt[1+x^2]');
+    });
+
+    test('多参数', () {
+      expect(toSymjaSyntax('max(x, y)'), 'Max[x, y]');
+    });
+
+    test('已经是对的 Symja 写法原样保留', () {
+      expect(toSymjaSyntax('Sin[x]'), 'Sin[x]');
+      expect(toSymjaSyntax('x^2 + 1'), 'x^2 + 1');
+    });
+  });
+
+  group('常量', () {
+    test('pi', () {
+      expect(toSymjaSyntax('pi'), 'Pi');
+      expect(toSymjaSyntax('2*pi'), '2*Pi');
+    });
+
+    test('e 独立成词才转,且不与数字相邻', () {
+      expect(toSymjaSyntax('e^x'), 'E^x');
+      expect(toSymjaSyntax('e'), 'E');
+      // 科学计数法不能被拆掉
+      expect(toSymjaSyntax('1e-3'), '1e-3');
+      expect(toSymjaSyntax('2.5e10'), '2.5e10');
+    });
+  });
+
+  group('普通括号不能被当成函数调用', () {
+    test('分组括号保持原样', () {
+      expect(toSymjaSyntax('(x+1)*2'), '(x+1)*2');
+      expect(toSymjaSyntax('((x))'), '((x))');
+    });
+
+    test('未知名字后面跟括号也不动', () {
+      // 不是我们认识的函数名,就当它是用户自己的变量/函数,原样留着
+      expect(toSymjaSyntax('f(x)'), 'f(x)');
+      expect(toSymjaSyntax('myfunc(x+1)'), 'myfunc(x+1)');
+    });
+
+    test('函数与分组混在一起', () {
+      expect(toSymjaSyntax('sin(x)*(x+1)'), 'Sin[x]*(x+1)');
+      expect(toSymjaSyntax('sin(x)^2 + cos(x)^2'), 'Sin[x]^2 + Cos[x]^2');
+    });
+  });
+
+  group('隐式乘法', () {
+    test('数字紧跟字母补乘号', () {
+      expect(toSymjaSyntax('2x'), '2*x');
+      expect(toSymjaSyntax('3sin(x)'), '3*Sin[x]');
+    });
+
+    test('单独的系数不动', () {
+      expect(toSymjaSyntax('2*x'), '2*x');
+      expect(toSymjaSyntax('2'), '2');
+    });
+  });
+
+  group('symjaRewriteOf 只在真的改了才返回', () {
+    test('改了', () {
+      expect(symjaRewriteOf('sin(x)'), 'Sin[x]');
+    });
+    test('没改', () {
+      expect(symjaRewriteOf('Sin[x]'), isNull);
+      expect(symjaRewriteOf('x^2'), isNull);
+    });
+  });
+
+  group('不该崩', () {
+    test('空串与怪输入', () {
+      expect(toSymjaSyntax(''), '');
+      expect(toSymjaSyntax(')'), ')'); // 多余的右括号原样留着,让 Symja 去报错
+      expect(toSymjaSyntax('sin('), 'Sin[');
+    });
+  });
+}