function_plotter.dart 8.1 KB

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