// tools/function_plotter.dart // // 函数图像绘制:一个或多个 y = f(x) 原样交给 Symja 采样,再用 [CurvePlot] 画。 // // 采样部分(区间、点数、由 sample 拿点)照旧不变;变的只是外壳 —— 输入框交给 // [ToolScaffold],绘制按钮就是外壳的「计算」,重置按钮也是外壳自带的。 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 { /// 横纵范围在**画图之前**定:两个滑动条放在「计算」按钮上方, /// 采样区间和显示窗口都用它们(原来滑条在图下面、只管显示窗口, /// 曲线拉出去就断在那儿)。 RangeValues _xRange = const RangeValues(-10, 10); RangeValues _yRange = const RangeValues(-10, 10); /// 滑条的取值范围(比默认宽,方便拉大拉小)。 static const RangeValues _rangeLimits = RangeValues(-50, 50); /// 每单位 x 采几个点。 static const double _perUnit = 60; 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, ), ]; @override void initState() { super.initState(); // 打开就该看到一条曲线:第一个框预置 x^2(autoRun 立刻画) _exprs.first.controller.text = 'x^2'; } 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 _rangeRow( String label, RangeValues v, ValueChanged onChanged, ) => Row( children: [ SizedBox( width: 64, child: Text(label, style: const TextStyle(fontSize: 12)), ), Expanded( child: RangeSlider( values: v, min: _rangeLimits.start, max: _rangeLimits.end, divisions: (_rangeLimits.end - _rangeLimits.start).round(), labels: RangeLabels('${v.start.round()}', '${v.end.round()}'), onChanged: onChanged, ), ), SizedBox( width: 76, child: Text( '${v.start.round()} ~ ${v.end.round()}', textAlign: TextAlign.right, style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), ), ), ], ); /// 两个范围滑动条放在「计算」上方:**先定范围再画图**。 Widget _intro() => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _rangeRow('横轴范围', _xRange, (v) => setState(() => _xRange = v)), _rangeRow('纵轴范围', _yRange, (v) => setState(() => _yRange = v)), 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 series = []; final failed = []; final from = _xRange.start, to = _xRange.end; final count = ((to - from) * _perUnit).round().clamp(200, 4000); // 逐条采样:一条失败只记下它自己,别的照画。 for (var i = 0; i < _exprs.length; i++) { final raw = _exprs[i].text; if (raw.isEmpty) continue; try { // 表达式原样交给 Symja 解析,谁解析谁负责。 final s = await session.sample( raw, variable: 'x', from: from, to: to, count: count, ); series.add( CurveSeries( label: raw, color: _curveColors[i % _curveColors.length], // 非有限值(极点处的 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: CurvePlot( series: series, // 窗口由上面两个滑动条定死,图自己的滑条收起来 xWindow: (from, to), yWindow: (_yRange.start, _yRange.end), showControls: false, ), ), ), if (failed.isNotEmpty) ToolText('未能绘制的函数', failed.join('\n')), ]; }, ); }