// tools/function_plotter.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../curve_plot.dart'; import '../expr_syntax.dart'; import '../symja.dart'; /// 函数图像绘制:把一个或多个 y = f(x) 交给 Symja 采样,再用项目自己的 /// [CurvePlot] 画出来。 /// /// 为什么改成按钮触发的异步绘制:Symja 的求值都跑在原生侧的专用线程上,只能 /// 异步拿结果;旧的 `math_expressions` 是同步的,才能每次 build 都重算一遍。 /// 现在按下「绘制」采样一次,采样期间禁用按钮并显示忙碌状态。 class FunctionPlotterPage extends StatefulWidget { const FunctionPlotterPage({super.key}); @override State createState() => _FunctionPlotterPageState(); } class _FunctionPlotterPageState extends State { static const int _maxCurves = 5; /// 每条曲线的固定颜色,下标与输入框一一对应(沿用旧版配色)。 static const List _curveColors = [ Colors.blue, Colors.red, Colors.green, Colors.orange, Colors.purple, ]; final List _exprs = [ TextEditingController(text: 'x^2'), ]; final TextEditingController _xMin = TextEditingController(text: '-5'); final TextEditingController _xMax = TextEditingController(text: '5'); final TextEditingController _yMin = TextEditingController(text: '-10'); final TextEditingController _yMax = TextEditingController(text: '10'); List _series = const []; /// 上一次绘制实际使用的 y 范围(没绘制过就是 null,交给 CurvePlot 自动定界)。 double? _plotYMin; double? _plotYMax; /// 每个输入框的错误信息,与 [_exprs] 下标对齐;null 表示该项正常。 List _errors = const []; /// 坐标范围本身的错误(与某条曲线无关),显示在绘图区。 String? _rangeError; bool _busy = false; @override void initState() { super.initState(); // 首帧后再绘制:context.read 需要 element 已经挂载到 Provider 之下。 WidgetsBinding.instance.addPostFrameCallback((_) => _plot()); } @override void dispose() { for (final c in _exprs) { c.dispose(); } _xMin.dispose(); _xMax.dispose(); _yMin.dispose(); _yMax.dispose(); super.dispose(); } void _addCurve() { if (_exprs.length >= _maxCurves) return; setState(() => _exprs.add(TextEditingController())); } void _removeCurve(int index) { if (_exprs.length <= 1) return; setState(() => _exprs.removeAt(index).dispose()); } Future _plot() async { if (_busy) return; final xMin = double.tryParse(_xMin.text.trim()); final xMax = double.tryParse(_xMax.text.trim()); final yMin = double.tryParse(_yMin.text.trim()); final yMax = double.tryParse(_yMax.text.trim()); if (xMin == null || xMax == null || yMin == null || yMax == null) { setState(() => _rangeError = '请输入有效的坐标范围'); return; } if (xMin >= xMax || yMin >= yMax) { setState(() => _rangeError = '最小值必须小于最大值'); return; } final session = context.read(); setState(() { _busy = true; _rangeError = null; _plotYMin = yMin; _plotYMax = yMax; // 每次重绘都重建,长度与输入框对齐,旧错误自然被清掉。 _errors = List.filled(_exprs.length, null); }); final result = []; // 逐条采样:一条失败不影响其它条,错误只挂到它自己那一栏。 for (var i = 0; i < _exprs.length; i++) { final raw = _exprs[i].text.trim(); if (raw.isEmpty) continue; try { // 用户可能沿用旧的小写括号语法,先转成 Symja 语法再采样。 final expr = toSymjaSyntax(raw); final s = await session.sample( expr, variable: 'x', from: xMin, to: xMax, count: 240, ); if (!mounted) return; result.add(CurveSeries( label: raw, color: _curveColors[i], // 非有限值(极点处的 ComplexInfinity 等)原样保留, // CurvePlot 会把它们当成抬笔断点,这里不做过滤。 points: [ for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]), ], )); } on SymjaException catch (e) { if (!mounted) return; _errors[i] = '$e'; } } if (!mounted) return; setState(() { _series = result; _busy = false; }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('函数图像绘制', style: TextStyle(color: Colors.black)), backgroundColor: Colors.white, elevation: 1, actions: [ IconButton( icon: const Icon(Icons.refresh), onPressed: _busy ? null : _plot, tooltip: '重新绘制', ), ], ), body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ _buildInputs(), const SizedBox(height: 16), SizedBox( height: MediaQuery.of(context).size.height * 0.5, child: _buildPlotArea(), ), ], ), ), ), ); } Widget _buildInputs() { return Card( elevation: 4, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( '函数表达式 (使用 x 作为变量)', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 10), const Text( '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, pi, e', style: TextStyle(fontSize: 12, color: Colors.grey), ), const SizedBox(height: 10), LayoutBuilder( builder: (context, constraints) { final fields = [ _buildRangeField(_xMin, 'x最小值'), _buildRangeField(_xMax, 'x最大值'), _buildRangeField(_yMin, 'y最小值'), _buildRangeField(_yMax, 'y最大值'), ]; // 窄屏竖向排,宽屏横向排。 if (constraints.maxWidth < 600) { return Column( children: [ for (var i = 0; i < fields.length; i++) ...[ if (i > 0) const SizedBox(height: 10), fields[i], ], ], ); } return Row( children: [ for (var i = 0; i < fields.length; i++) ...[ if (i > 0) const SizedBox(width: 10), Expanded(child: fields[i]), ], ], ); }, ), const SizedBox(height: 4), const Text( '注: x 范围决定采样区间,y 范围决定显示窗口;图上还能双指缩放、拖动、双击复位。', style: TextStyle(fontSize: 11, color: Colors.grey), ), const SizedBox(height: 10), Container( constraints: const BoxConstraints(maxHeight: 240), child: ListView.builder( shrinkWrap: true, itemCount: _exprs.length, itemBuilder: _buildExprRow, ), ), const SizedBox(height: 10), Row( children: [ if (_exprs.length < _maxCurves) OutlinedButton.icon( onPressed: _busy ? null : _addCurve, icon: const Icon(Icons.add), label: const Text('添加函数'), ), const Spacer(), 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( backgroundColor: Colors.blue, foregroundColor: Colors.white, ), ), ], ), ], ), ), ); } Widget _buildExprRow(BuildContext context, int index) { final raw = _exprs[index].text.trim(); // 只有在语法真的被改写时才提示,避免对已经正确的输入刷屏。 final rewrite = raw.isEmpty ? null : symjaRewriteOf(raw); return Padding( padding: const EdgeInsets.symmetric(vertical: 5), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 20, height: 20, margin: const EdgeInsets.only(top: 16), decoration: BoxDecoration( color: _curveColors[index], shape: BoxShape.circle, ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: _exprs[index], // 输入时重建,让下面的语法转换提示实时更新。 onChanged: (_) => setState(() {}), decoration: InputDecoration( hintText: '例如: x^2, sin(x), Sin[x]', border: const OutlineInputBorder(), errorText: index < _errors.length ? _errors[index] : null, ), ), if (rewrite != null) Padding( padding: const EdgeInsets.only(top: 4, left: 4), child: Text( '已按 Symja 语法解释为: $rewrite', style: TextStyle(fontSize: 11, color: Colors.grey[600]), ), ), ], ), ), const SizedBox(width: 10), if (_exprs.length > 1) IconButton( icon: const Icon(Icons.remove, color: Colors.red), tooltip: '删除该函数', onPressed: () => _removeCurve(index), ), ], ), ); } Widget _buildRangeField(TextEditingController controller, String label) { return TextField( controller: controller, decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), keyboardType: TextInputType.numberWithOptions( signed: true, decimal: true, ), ); } Widget _buildPlotArea() { return Card( elevation: 4, child: Padding( padding: const EdgeInsets.all(16), child: Column( children: [ const Text( '函数图像', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 10), Expanded(child: _buildPlot()), ], ), ), ); } Widget _buildPlot() { if (_busy) { return const Center(child: CircularProgressIndicator()); } if (_rangeError != null) { return Center( child: Text(_rangeError!, style: const TextStyle(color: Colors.red)), ); } if (_series.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: const [ Icon(Icons.functions, size: 50, color: Colors.grey), SizedBox(height: 10), Text('暂无函数图像可显示', style: TextStyle(color: Colors.grey)), Text( '请输入函数表达式后点击「绘制图像」', style: TextStyle(color: Colors.grey, fontSize: 12), ), ], ), ); } return CurvePlot(series: _series, yMin: _plotYMin, yMax: _plotYMax); } }