// tools/parametric_plotter.dart // // 参数方程绘图:x = f(t), y = g(t),用 Symja 采样后交给 [CurvePlot] 画。 // // 采样要点:Symja 的 `sample` 一次只能算一个表达式,所以 x(t) 和 y(t) 各调用 // 一次,参数区间和点数完全一致,再把两个结果按下标配成 (x, y) 点。 import 'package:flutter/material.dart'; import '../curve_plot.dart'; import '../symja.dart'; import 'tool_kit.dart'; class ParametricPlotterPage extends StatefulWidget { const ParametricPlotterPage({super.key}); @override State createState() => _ParametricPlotterPageState(); } class _ParametricPlotterPageState extends State { final ToolField _x = ToolField('x = f(t)', hint: '例如 cos(t)、t^2', keyboard: TextInputType.text); final ToolField _y = ToolField('y = g(t)', hint: '例如 sin(t)、t^3', keyboard: TextInputType.text); final ToolField _tMin = ToolField('t 最小值', hint: '例如 0'); final ToolField _tMax = ToolField('t 最大值', hint: '例如 6.3'); final ToolField _step = ToolField('步长', hint: '例如 0.1'); /// 稳定的列表对象:外壳靠 `identical(fields)` 判断要不要清空结果, /// 每次 build 新建 List 会让主题一变结果就被清掉。 late final List _fields = [_x, _y, _tMin, _tMax, _step]; /// 给外壳换 key 用。示例按钮改完输入框后把 key 一换,外壳就会重建并 /// autoRun,于是"点了示例就重算"这件事不用外壳额外开放接口。 int _runToken = 0; @override void initState() { super.initState(); _x.controller.text = 'cos(t)'; _y.controller.text = 'sin(t)'; _tMin.controller.text = '0'; _tMax.controller.text = '6.3'; _step.controller.text = '0.1'; } void _applyPreset(String x, String y, String tMin, String tMax) { setState(() { _x.controller.text = x; _y.controller.text = y; _tMin.controller.text = tMin; _tMax.controller.text = tMax; _step.controller.text = '0.1'; _runToken++; // 换 key → 外壳重建 → autoRun 重新采样 }); } Widget _intro() => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Wrap( spacing: 8, runSpacing: 8, children: [ ActionChip( label: const Text('椭圆'), onPressed: () => _applyPreset('2*cos(t)', 'sin(t)', '0', '6.3'), ), ActionChip( label: const Text('螺旋线'), onPressed: () => _applyPreset('t*cos(t)', 't*sin(t)', '0', '12.6'), ), ActionChip( label: const Text('抛物线'), onPressed: () => _applyPreset('t', 't^2', '-2', '2'), ), ActionChip( label: const Text('心形线'), onPressed: () => _applyPreset( '2*cos(t)-cos(2*t)', '2*sin(t)-sin(2*t)', '0', '6.3'), ), ], ), ], ); @override Widget build(BuildContext context) => ToolScaffold( // key 一换,外壳整体重建并重新 autoRun —— 示例按钮靠它重算。 key: ValueKey(_runToken), title: '参数方程绘图', autoRun: true, fields: _fields, intro: _intro(), compute: (session) async { final xRaw = _x.text; final yRaw = _y.text; final tMinRaw = _tMin.text; final tMaxRaw = _tMax.text; final stepRaw = _step.text; // 校验:都是格式检查,Dart 里做即可。 if (xRaw.isEmpty || yRaw.isEmpty) { throw Exception('请填写 x(t) 和 y(t) 表达式'); } final tMin = double.tryParse(tMinRaw); final tMax = double.tryParse(tMaxRaw); final step = double.tryParse(stepRaw); if (tMin == null || tMax == null || step == null) { throw Exception('请输入有效的 t 范围与步长'); } if (!tMin.isFinite || !tMax.isFinite || !step.isFinite) { throw Exception('t 范围与步长必须是有限数'); } if (tMin >= tMax) { throw Exception('t 最小值必须小于 t 最大值'); } if (step <= 0) { throw Exception('步长必须大于 0'); } // 把用户给的步长换算成点数,再做上下限保护,避免点数过多拖垮求值。 final n = (((tMax - tMin) / step).round() + 1).clamp(2, 5000).toInt(); final List series; try { // 同一个 t 区间、同一点数分别采样两条曲线——Symja 一次只能算一个表达式。 final xs = await session.sample( xRaw, variable: 't', from: tMin, to: tMax, count: n, ); final ys = await session.sample( yRaw, variable: 't', from: tMin, to: tMax, count: n, ); final m = xs.ys.length < ys.ys.length ? xs.ys.length : ys.ys.length; series = [ CurveSeries( label: 'x=$xRaw, y=$yRaw', color: Colors.blue, // NaN(极点/无定义点)照传,CurvePlot 会当成断点抬笔。 points: [ for (var i = 0; i < m; i++) Offset(xs.ys[i], ys.ys[i]), ], ), ]; } on SymjaException catch (e) { throw Exception('采样失败:$e'); } return [ ToolChart( '参数方程图像', // 内部用 Expanded,必须给确定高度;参数曲线常常是圆/椭圆, // 必须等比例(equalAspect: true)。 SizedBox( height: 360, child: CurvePlot(series: series, equalAspect: true), ), ), ToolText( '方程', 'x = $xRaw,y = $yRaw;t ∈ [$tMinRaw, $tMaxRaw],' '步长 $stepRaw(共 $n 个采样点)', ), ]; }, ); }