// tools/parametric_plotter.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../curve_plot.dart'; import '../symja.dart'; /// 参数方程绘图:x = f(t), y = g(t),用 Symja 采样后交给 [CurvePlot] 画。 /// /// 采样要点:Symja 的 `sample` 一次只能算一个表达式,所以 x(t) 和 y(t) 各调用 /// 一次,参数区间和点数完全一致,再把两个结果按下标配成 (x, y) 点。 class ParametricPlotterPage extends StatefulWidget { const ParametricPlotterPage({super.key}); @override State createState() => _ParametricPlotterPageState(); } class _ParametricPlotterPageState extends State { final TextEditingController _xCtrl = TextEditingController(text: 'cos(t)'); final TextEditingController _yCtrl = TextEditingController(text: 'sin(t)'); final TextEditingController _tMinCtrl = TextEditingController(text: '0'); final TextEditingController _tMaxCtrl = TextEditingController(text: '6.3'); final TextEditingController _stepCtrl = TextEditingController(text: '0.1'); List _series = const []; String? _error; bool _busy = false; @override void initState() { super.initState(); // 首帧后再绘制:context.read 需要 element 已经挂载到 Provider 之下。 WidgetsBinding.instance.addPostFrameCallback((_) => _plot()); } @override void dispose() { _xCtrl.dispose(); _yCtrl.dispose(); _tMinCtrl.dispose(); _tMaxCtrl.dispose(); _stepCtrl.dispose(); super.dispose(); } Future _plot() async { if (_busy) return; final xRaw = _xCtrl.text.trim(); final yRaw = _yCtrl.text.trim(); if (xRaw.isEmpty || yRaw.isEmpty) { setState(() => _error = '请填写 x(t) 和 y(t) 表达式'); return; } final tMin = double.tryParse(_tMinCtrl.text.trim()); final tMax = double.tryParse(_tMaxCtrl.text.trim()); final step = double.tryParse(_stepCtrl.text.trim()); if (tMin == null || tMax == null || step == null) { setState(() => _error = '请输入有效的 t 范围与步长'); return; } if (tMin >= tMax) { setState(() => _error = 't最小值必须小于t最大值'); return; } if (step <= 0) { setState(() => _error = '步长必须大于0'); return; } // Symja 的 sample 只接受「点数」,这里把用户给的步长换算成点数, // 保留步长输入的含义;再做上下限保护,避免点数过多拖垮求值。 final count = ((tMax - tMin) / step).round() + 1; final n = count.clamp(2, 5000).toInt(); final session = context.read(); setState(() { _busy = true; _error = null; }); List series = const []; 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, ); if (!mounted) return; 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) { if (!mounted) return; setState(() { _error = '$e'; _busy = false; }); return; } if (!mounted) return; setState(() { _series = series; _busy = false; }); } void _applyPreset(String xExpr, String yExpr, String tMin, String tMax) { setState(() { _xCtrl.text = xExpr; _yCtrl.text = yExpr; _tMinCtrl.text = tMin; _tMaxCtrl.text = tMax; _stepCtrl.text = '0.1'; }); _plot(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('参数方程绘图', style: TextStyle(color: Colors.black)), backgroundColor: Colors.white, elevation: 1, ), body: SingleChildScrollView( child: Column( children: [ _buildInputPanel(), _buildGraph(), _buildExamples(), ], ), ), ); } Widget _buildInputPanel() { return Card( elevation: 4, margin: const EdgeInsets.all(16), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '参数方程设置', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), _buildExprField(_xCtrl, 'x = f(t)', '例如: cos(t), t^2, Cos[t]'), const SizedBox(height: 12), _buildExprField(_yCtrl, 'y = g(t)', '例如: sin(t), t^3, Sin[t]'), const SizedBox(height: 16), Row( children: [ Expanded(child: _buildNumberField(_tMinCtrl, 't最小值')), const SizedBox(width: 12), Expanded(child: _buildNumberField(_tMaxCtrl, 't最大值')), const SizedBox(width: 12), Expanded(child: _buildNumberField(_stepCtrl, '步长')), ], ), const SizedBox(height: 12), if (_error != null) Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.red[50], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.red), ), child: Text(_error!, style: const TextStyle(color: Colors.red)), ), const SizedBox(height: 16), ElevatedButton.icon( onPressed: _busy ? null : _plot, icon: _busy ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.show_chart), label: Text(_busy ? '绘制中…' : '绘制图像'), style: ElevatedButton.styleFrom( minimumSize: const Size(double.infinity, 50), ), ), ], ), ), ); } /// 表达式输入框。内容原样交给 Symja 解析,这里不做任何转换。 Widget _buildExprField( TextEditingController controller, String label, String hint, ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: controller, decoration: InputDecoration( labelText: label, hintText: hint, border: const OutlineInputBorder(), ), ), ], ); } Widget _buildNumberField(TextEditingController controller, String label) { return TextField( controller: controller, decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), ), keyboardType: TextInputType.numberWithOptions( signed: true, decimal: true, ), ); } Widget _buildGraph() { return Card( elevation: 4, margin: const EdgeInsets.all(16), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '参数方程图像', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), // 用 AspectRatio 强制正方形,参数曲线的 x/y 比例才不会被拉伸。 AspectRatio( aspectRatio: 1, child: Container( decoration: BoxDecoration( border: Border.all(color: Colors.grey[300]!), borderRadius: BorderRadius.circular(8), ), child: _busy ? const Center(child: CircularProgressIndicator()) : (_series.isEmpty ? const Center( child: Text( '暂无数据', style: TextStyle(fontSize: 16, color: Colors.grey), ), ) : CurvePlot(series: _series)), ), ), const SizedBox(height: 16), Text( '方程: x = ${_xCtrl.text}, y = ${_yCtrl.text}', style: TextStyle(fontSize: 14, color: Colors.grey[600]), ), Text( '参数范围: t ∈ [${_tMinCtrl.text}, ${_tMaxCtrl.text}], 步长: ${_stepCtrl.text}', style: TextStyle(fontSize: 14, color: Colors.grey[600]), ), ], ), ), ); } Widget _buildExamples() { return Card( elevation: 4, margin: const EdgeInsets.all(16), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '示例方程', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ _buildExampleChip('椭圆', '2*cos(t)', 'sin(t)', '0', '6.3'), _buildExampleChip('螺旋线', 't*cos(t)', 't*sin(t)', '0', '12.6'), _buildExampleChip('抛物线', 't', 't^2', '-2', '2'), _buildExampleChip( '心形线', '2*cos(t)-cos(2*t)', '2*sin(t)-sin(2*t)', '0', '6.3', ), ], ), ], ), ), ); } Widget _buildExampleChip( String title, String xExpr, String yExpr, String tMin, String tMax, ) { return ActionChip( label: Text(title), onPressed: () => _applyPreset(xExpr, yExpr, tMin, tMax), backgroundColor: Colors.blue[50], ); } }