| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255 |
- // 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<FunctionPlotterPage> createState() => _FunctionPlotterPageState();
- }
- class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
- /// 固定的采样区间与点数。横纵范围由图下面的滑条控制,点取得密一些,
- /// 放大到一小段时也还有足够的点。
- static const double _sampleFrom = -10;
- static const double _sampleTo = 10;
- static const int _sampleCount = 800;
- static const int _maxCurves = 5;
- /// 每条曲线的固定颜色,下标与输入框一一对应。
- static const List<Color> _curveColors = <Color>[
- Colors.blue,
- Colors.red,
- Colors.green,
- Colors.orange,
- Colors.purple,
- ];
- /// 可增减的表达式输入框。列表**就地增删**(对象始终是同一个),
- /// 外壳靠 `identical(fields)` 判断要不要清空结果,换新 List 会把结果清掉。
- final List<ToolField> _exprs = <ToolField>[
- 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 = <CurveSeries>[];
- final failed = <String>[];
- // 逐条采样:一条失败只记下它自己,别的照画。
- 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: <Offset>[
- 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 <ToolOutput>[
- 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<CurveSeries> 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<CurveSeries> _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<void> _resample(double from, double to) async {
- if (_sampling) return; // 采样期间不排队:松开滑条后还会再报一次
- _sampling = true;
- try {
- final count = ((to - from) * _perUnit).round().clamp(400, 4000);
- final out = <CurveSeries>[];
- 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: <Offset>[
- 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,
- );
- }
|