// tools/function_plotter.dart // // 函数图像绘制:一个或多个 y = f(x) 原样交给 Symja 采样,再用 [CurvePlot] 画。 // // 采样部分(区间、点数、由 sample 拿点)照旧不变;变的只是外壳 —— 输入框交给 // [ToolScaffold],绘制按钮就是外壳的「计算」,重置按钮也是外壳自带的。 import 'dart:math' as math; import 'package:flutter/material.dart'; import '../curve_plot.dart'; import '../symja.dart'; import 'tool_kit.dart'; class FunctionPlotterPage extends StatefulWidget { const FunctionPlotterPage({super.key}); @override State createState() => _FunctionPlotterPageState(); } class _FunctionPlotterPageState extends State { /// 固定的采样区间与点数。横纵范围由图下面的滑条控制,点取得密一些, /// 放大到一小段时也还有足够的点。 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, ]; /// 可增减的表达式输入框。列表**就地增删**(对象始终是同一个), /// 外壳靠 `identical(fields)` 判断要不要清空结果,换新 List 会把结果清掉。 final List _exprs = [ ToolField( '函数 1', hint: '例如 x^2、Sin[x]、Sqrt[x]', keyboard: TextInputType.text, ), ]; void _addCurve() { if (_exprs.length >= _maxCurves) return; setState(() { _exprs.add(ToolField( '函数 ${_exprs.length + 1}', hint: '例如 x^2、Sin[x]、Sqrt[x]', keyboard: TextInputType.text, )); }); } void _removeCurve() { if (_exprs.length <= 1) return; // 不 dispose 被移除的控制器:外壳约定不 dispose 输入框, // 而且这一帧旧的 TextField 可能还引用着它,提前 dispose 会抛异常。 setState(() => _exprs.removeLast()); } Widget _intro() => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('函数表达式 (使用 x 作为变量)'), const Text( '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, pi, e', style: TextStyle(fontSize: 12), ), const SizedBox(height: 8), Row( children: [ OutlinedButton.icon( onPressed: _exprs.length < _maxCurves ? _addCurve : null, icon: const Icon(Icons.add), label: const Text('添加函数'), ), const SizedBox(width: 12), OutlinedButton.icon( onPressed: _exprs.length > 1 ? _removeCurve : null, icon: const Icon(Icons.remove), label: const Text('删除函数'), ), ], ), ], ); @override Widget build(BuildContext context) => ToolScaffold( title: '函数图像绘制', // 默认已经有一个 x^2,打开就该看到图。 autoRun: true, fields: _exprs, intro: _intro(), compute: (session) async { final curves = <({String expr, Color color})>[]; final series = []; final failed = []; // 逐条采样:一条失败只记下它自己,别的照画。 for (var i = 0; i < _exprs.length; i++) { final raw = _exprs[i].text; if (raw.isEmpty) continue; final color = _curveColors[i % _curveColors.length]; try { // 表达式原样交给 Symja 解析,谁解析谁负责。 final s = await session.sample( raw, variable: 'x', from: _sampleFrom, to: _sampleTo, count: _sampleCount, ); curves.add((expr: raw, color: color)); series.add(CurveSeries( label: raw, color: color, // 非有限值(极点处的 ComplexInfinity 等)原样保留, // CurvePlot 会把它们当成抬笔断点。 points: [ for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]), ], )); } on SymjaException catch (e) { failed.add('$raw:$e'); } } if (series.isEmpty && failed.isEmpty) { throw Exception('请至少填写一个函数表达式'); } return [ if (series.isNotEmpty) ToolChart( '函数图像', // CurvePlot 内部用 Expanded,必须给一个确定高度, // 否则放在外层 Column 里会因为高度无界而报错。 SizedBox( height: 360, child: _FunctionChart( session: session, curves: curves, initialSeries: series, initialFrom: _sampleFrom, initialTo: _sampleTo, ), ), ), if (failed.isNotEmpty) ToolText('未能绘制的函数', failed.join('\n')), ]; }, ); } /// 会跟着横向滑动条**重新采样**的函数图像。 /// /// 采样区间原来是写死的 [-10, 10],滑动条一拉出去曲线就断在那儿了(用户实测 /// 反馈"调整滑动条后图像不会自动延展")。这里接住 CurvePlot 报回来的窗口, /// 只要视野跑出已采样的范围就往外扩一圈、把函数重新采一遍 —— 视野里永远有曲线。 class _FunctionChart extends StatefulWidget { const _FunctionChart({ required this.session, required this.curves, required this.initialSeries, required this.initialFrom, required this.initialTo, }); final SymjaSession session; /// 要画的函数(表达式 + 颜色)。重新采样时按同一份清单再采一遍。 final List<({String expr, Color color})> curves; /// 页面已经采好的第一批点,免得这里再采一次。 final List initialSeries; final double initialFrom; final double initialTo; @override State<_FunctionChart> createState() => _FunctionChartState(); } class _FunctionChartState extends State<_FunctionChart> { /// 每单位 x 采几个点。放大到一小段时也还画得平滑。 static const double _perUnit = 60; late double _from = widget.initialFrom; late double _to = widget.initialTo; late List _series = widget.initialSeries; bool _sampling = false; /// 视野跑出已采样范围(两边各留 10% 余量)才动手,否则拖动过程中会一直重采。 void _onView(double x0, double x1) { final span = _to - _from; if (x0 >= _from + span * 0.1 && x1 <= _to - span * 0.1) return; final from = math.min(_from, x0 - span * 0.15); final to = math.max(_to, x1 + span * 0.15); _resample(from, to); } Future _resample(double from, double to) async { if (_sampling) return; // 采样期间不排队:松开滑条后还会再报一次 _sampling = true; try { final count = ((to - from) * _perUnit).round().clamp(400, 4000); final out = []; for (final c in widget.curves) { try { final s = await widget.session.sample( c.expr, variable: 'x', from: from, to: to, count: count, ); out.add(CurveSeries( label: c.expr, color: c.color, points: [ for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]), ], )); } on SymjaException { // 拉远之后算不动了(比如跑到定义域外、或被取消):留着原来的曲线, // 别把已经画出来的图清空。 } } if (!mounted) return; // 全都失败就当这次没发生过 —— 至少不会把图变空 if (out.length == widget.curves.length) { setState(() { _from = from; _to = to; _series = out; }); } } finally { _sampling = false; } } @override Widget build(BuildContext context) => CurvePlot( series: _series, onViewChanged: _onView, ); }