function_plotter.dart 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. // tools/function_plotter.dart
  2. //
  3. // 函数图像绘制:一个或多个 y = f(x) 原样交给 Symja 采样,再用 [CurvePlot] 画。
  4. //
  5. // 采样部分(区间、点数、由 sample 拿点)照旧不变;变的只是外壳 —— 输入框交给
  6. // [ToolScaffold],绘制按钮就是外壳的「计算」,重置按钮也是外壳自带的。
  7. import 'dart:math' as math;
  8. import 'package:flutter/material.dart';
  9. import '../curve_plot.dart';
  10. import '../symja.dart';
  11. import 'tool_kit.dart';
  12. class FunctionPlotterPage extends StatefulWidget {
  13. const FunctionPlotterPage({super.key});
  14. @override
  15. State<FunctionPlotterPage> createState() => _FunctionPlotterPageState();
  16. }
  17. class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
  18. /// 固定的采样区间与点数。横纵范围由图下面的滑条控制,点取得密一些,
  19. /// 放大到一小段时也还有足够的点。
  20. static const double _sampleFrom = -10;
  21. static const double _sampleTo = 10;
  22. static const int _sampleCount = 800;
  23. static const int _maxCurves = 5;
  24. /// 每条曲线的固定颜色,下标与输入框一一对应。
  25. static const List<Color> _curveColors = <Color>[
  26. Colors.blue,
  27. Colors.red,
  28. Colors.green,
  29. Colors.orange,
  30. Colors.purple,
  31. ];
  32. /// 可增减的表达式输入框。列表**就地增删**(对象始终是同一个),
  33. /// 外壳靠 `identical(fields)` 判断要不要清空结果,换新 List 会把结果清掉。
  34. final List<ToolField> _exprs = <ToolField>[
  35. ToolField(
  36. '函数 1',
  37. hint: '例如 x^2、Sin[x]、Sqrt[x]',
  38. keyboard: TextInputType.text,
  39. ),
  40. ];
  41. void _addCurve() {
  42. if (_exprs.length >= _maxCurves) return;
  43. setState(() {
  44. _exprs.add(ToolField(
  45. '函数 ${_exprs.length + 1}',
  46. hint: '例如 x^2、Sin[x]、Sqrt[x]',
  47. keyboard: TextInputType.text,
  48. ));
  49. });
  50. }
  51. void _removeCurve() {
  52. if (_exprs.length <= 1) return;
  53. // 不 dispose 被移除的控制器:外壳约定不 dispose 输入框,
  54. // 而且这一帧旧的 TextField 可能还引用着它,提前 dispose 会抛异常。
  55. setState(() => _exprs.removeLast());
  56. }
  57. Widget _intro() => Column(
  58. crossAxisAlignment: CrossAxisAlignment.start,
  59. children: [
  60. const Text('函数表达式 (使用 x 作为变量)'),
  61. const Text(
  62. '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, pi, e',
  63. style: TextStyle(fontSize: 12),
  64. ),
  65. const SizedBox(height: 8),
  66. Row(
  67. children: [
  68. OutlinedButton.icon(
  69. onPressed: _exprs.length < _maxCurves ? _addCurve : null,
  70. icon: const Icon(Icons.add),
  71. label: const Text('添加函数'),
  72. ),
  73. const SizedBox(width: 12),
  74. OutlinedButton.icon(
  75. onPressed: _exprs.length > 1 ? _removeCurve : null,
  76. icon: const Icon(Icons.remove),
  77. label: const Text('删除函数'),
  78. ),
  79. ],
  80. ),
  81. ],
  82. );
  83. @override
  84. Widget build(BuildContext context) => ToolScaffold(
  85. title: '函数图像绘制',
  86. // 默认已经有一个 x^2,打开就该看到图。
  87. autoRun: true,
  88. fields: _exprs,
  89. intro: _intro(),
  90. compute: (session) async {
  91. final curves = <({String expr, Color color})>[];
  92. final series = <CurveSeries>[];
  93. final failed = <String>[];
  94. // 逐条采样:一条失败只记下它自己,别的照画。
  95. for (var i = 0; i < _exprs.length; i++) {
  96. final raw = _exprs[i].text;
  97. if (raw.isEmpty) continue;
  98. final color = _curveColors[i % _curveColors.length];
  99. try {
  100. // 表达式原样交给 Symja 解析,谁解析谁负责。
  101. final s = await session.sample(
  102. raw,
  103. variable: 'x',
  104. from: _sampleFrom,
  105. to: _sampleTo,
  106. count: _sampleCount,
  107. );
  108. curves.add((expr: raw, color: color));
  109. series.add(CurveSeries(
  110. label: raw,
  111. color: color,
  112. // 非有限值(极点处的 ComplexInfinity 等)原样保留,
  113. // CurvePlot 会把它们当成抬笔断点。
  114. points: <Offset>[
  115. for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]),
  116. ],
  117. ));
  118. } on SymjaException catch (e) {
  119. failed.add('$raw:$e');
  120. }
  121. }
  122. if (series.isEmpty && failed.isEmpty) {
  123. throw Exception('请至少填写一个函数表达式');
  124. }
  125. return <ToolOutput>[
  126. if (series.isNotEmpty)
  127. ToolChart(
  128. '函数图像',
  129. // CurvePlot 内部用 Expanded,必须给一个确定高度,
  130. // 否则放在外层 Column 里会因为高度无界而报错。
  131. SizedBox(
  132. height: 360,
  133. child: _FunctionChart(
  134. session: session,
  135. curves: curves,
  136. initialSeries: series,
  137. initialFrom: _sampleFrom,
  138. initialTo: _sampleTo,
  139. ),
  140. ),
  141. ),
  142. if (failed.isNotEmpty)
  143. ToolText('未能绘制的函数', failed.join('\n')),
  144. ];
  145. },
  146. );
  147. }
  148. /// 会跟着横向滑动条**重新采样**的函数图像。
  149. ///
  150. /// 采样区间原来是写死的 [-10, 10],滑动条一拉出去曲线就断在那儿了(用户实测
  151. /// 反馈"调整滑动条后图像不会自动延展")。这里接住 CurvePlot 报回来的窗口,
  152. /// 只要视野跑出已采样的范围就往外扩一圈、把函数重新采一遍 —— 视野里永远有曲线。
  153. class _FunctionChart extends StatefulWidget {
  154. const _FunctionChart({
  155. required this.session,
  156. required this.curves,
  157. required this.initialSeries,
  158. required this.initialFrom,
  159. required this.initialTo,
  160. });
  161. final SymjaSession session;
  162. /// 要画的函数(表达式 + 颜色)。重新采样时按同一份清单再采一遍。
  163. final List<({String expr, Color color})> curves;
  164. /// 页面已经采好的第一批点,免得这里再采一次。
  165. final List<CurveSeries> initialSeries;
  166. final double initialFrom;
  167. final double initialTo;
  168. @override
  169. State<_FunctionChart> createState() => _FunctionChartState();
  170. }
  171. class _FunctionChartState extends State<_FunctionChart> {
  172. /// 每单位 x 采几个点。放大到一小段时也还画得平滑。
  173. static const double _perUnit = 60;
  174. late double _from = widget.initialFrom;
  175. late double _to = widget.initialTo;
  176. late List<CurveSeries> _series = widget.initialSeries;
  177. bool _sampling = false;
  178. /// 视野跑出已采样范围(两边各留 10% 余量)才动手,否则拖动过程中会一直重采。
  179. void _onView(double x0, double x1) {
  180. final span = _to - _from;
  181. if (x0 >= _from + span * 0.1 && x1 <= _to - span * 0.1) return;
  182. final from = math.min(_from, x0 - span * 0.15);
  183. final to = math.max(_to, x1 + span * 0.15);
  184. _resample(from, to);
  185. }
  186. Future<void> _resample(double from, double to) async {
  187. if (_sampling) return; // 采样期间不排队:松开滑条后还会再报一次
  188. _sampling = true;
  189. try {
  190. final count = ((to - from) * _perUnit).round().clamp(400, 4000);
  191. final out = <CurveSeries>[];
  192. for (final c in widget.curves) {
  193. try {
  194. final s = await widget.session.sample(
  195. c.expr,
  196. variable: 'x',
  197. from: from,
  198. to: to,
  199. count: count,
  200. );
  201. out.add(CurveSeries(
  202. label: c.expr,
  203. color: c.color,
  204. points: <Offset>[
  205. for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]),
  206. ],
  207. ));
  208. } on SymjaException {
  209. // 拉远之后算不动了(比如跑到定义域外、或被取消):留着原来的曲线,
  210. // 别把已经画出来的图清空。
  211. }
  212. }
  213. if (!mounted) return;
  214. // 全都失败就当这次没发生过 —— 至少不会把图变空
  215. if (out.length == widget.curves.length) {
  216. setState(() {
  217. _from = from;
  218. _to = to;
  219. _series = out;
  220. });
  221. }
  222. } finally {
  223. _sampling = false;
  224. }
  225. }
  226. @override
  227. Widget build(BuildContext context) => CurvePlot(
  228. series: _series,
  229. onViewChanged: _onView,
  230. );
  231. }