// tools/function_plotter.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../curve_plot.dart'; import '../symja.dart'; /// 函数图像绘制:把一个或多个 y = f(x) 交给 Symja 采样,再用项目自己的 /// [CurvePlot] 画出来。 /// /// 为什么改成按钮触发的异步绘制:Symja 的求值都跑在原生侧的专用线程上,只能 /// 异步拿结果;旧的 `math_expressions` 是同步的,才能每次 build 都重算一遍。 /// 现在按下「绘制」采样一次,采样期间禁用按钮并显示忙碌状态。 class FunctionPlotterPage extends StatefulWidget { const FunctionPlotterPage({super.key}); @override State createState() => _FunctionPlotterPageState(); } class _FunctionPlotterPageState extends State { /// 固定的采样区间与点数。 /// /// 以前这里是用户可以填的 x/y 范围;现在横纵范围由图下面的滑动条控制, /// 输入框就用不上了。点取得密一些,放大到一小段时才有足够的点。 static const double _sampleFrom = -10; static const double _sampleTo = 10; static const int _sampleCount = 800; static const int _maxCurves = 5; /// 每条曲线的固定颜色,下标与输入框一一对应(沿用旧版配色)。 static const List _curveColors = [ Colors.blue, Colors.red, Colors.green, Colors.orange, Colors.purple, ]; final List _exprs = [ TextEditingController(text: 'x^2'), ]; List _series = const []; /// 每个输入框的错误信息,与 [_exprs] 下标对齐;null 表示该项正常。 List _errors = const []; /// 坐标范围本身的错误(与某条曲线无关),显示在绘图区。 bool _busy = false; @override void initState() { super.initState(); // 首帧后再绘制:context.read 需要 element 已经挂载到 Provider 之下。 WidgetsBinding.instance.addPostFrameCallback((_) => _plot()); } @override void dispose() { for (final c in _exprs) { c.dispose(); } super.dispose(); } void _addCurve() { if (_exprs.length >= _maxCurves) return; setState(() => _exprs.add(TextEditingController())); } void _removeCurve(int index) { if (_exprs.length <= 1) return; setState(() => _exprs.removeAt(index).dispose()); } Future _plot() async { if (_busy) return; final session = context.read(); setState(() { _busy = true; // 每次重绘都重建,长度与输入框对齐,旧错误自然被清掉。 _errors = List.filled(_exprs.length, null); }); final result = []; // 逐条采样:一条失败不影响其它条,错误只挂到它自己那一栏。 for (var i = 0; i < _exprs.length; i++) { final raw = _exprs[i].text.trim(); if (raw.isEmpty) continue; try { // 表达式**原样交给 Symja 解析** —— 不再自己转换,谁解析谁负责, // 免得"工具里算得出来、控制台里又不对"。 final expr = raw; // 采样区间固定,不再由输入框决定 —— 横纵范围交给图下面的滑动条。 // 点取得密一些(800 个),这样放大到一小段时也还有足够的点。 final s = await session.sample( expr, variable: 'x', from: _sampleFrom, to: _sampleTo, count: _sampleCount, ); if (!mounted) return; result.add(CurveSeries( label: raw, color: _curveColors[i], // 非有限值(极点处的 ComplexInfinity 等)原样保留, // CurvePlot 会把它们当成抬笔断点,这里不做过滤。 points: [ 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'; } } if (!mounted) return; setState(() { _series = result; _busy = false; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('函数图像绘制', style: TextStyle(color: Colors.black)), backgroundColor: Colors.white, elevation: 1, actions: [ IconButton( icon: const Icon(Icons.refresh), onPressed: _busy ? null : _plot, tooltip: '重新绘制', ), ], ), body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ _buildInputs(), const SizedBox(height: 16), SizedBox( height: MediaQuery.of(context).size.height * 0.5, child: _buildPlotArea(), ), ], ), ), ), ); } Widget _buildInputs() { return Card( elevation: 4, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '函数表达式 (使用 x 作为变量)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 10), const Text( '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, pi, e', style: TextStyle(fontSize: 12, color: Colors.grey), ), const SizedBox(height: 10), const SizedBox(height: 10), Container( constraints: const BoxConstraints(maxHeight: 240), child: ListView.builder( shrinkWrap: true, itemCount: _exprs.length, itemBuilder: _buildExprRow, ), ), 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 _buildExprRow(BuildContext context, int index) { 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], decoration: InputDecoration( hintText: '例如: x^2, Sin[x], Sqrt[x], Exp[x]', border: const OutlineInputBorder(), errorText: index < _errors.length ? _errors[index] : null, ), ), ], ), ), const SizedBox(width: 10), if (_exprs.length > 1) IconButton( icon: const Icon(Icons.remove, color: Colors.red), tooltip: '删除该函数', onPressed: () => _removeCurve(index), ), ], ), ); } Widget _buildPlotArea() { return Card( elevation: 4, child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ const Text( '函数图像', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 10), Expanded(child: _buildPlot()), ], ), ), ); } Widget _buildPlot() { if (_busy) { return const Center(child: CircularProgressIndicator()); } if (_series.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, 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), ), ], ), ); } return CurvePlot(series: _series); } }