function_plotter.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. // tools/function_plotter.dart
  2. import 'package:flutter/material.dart';
  3. import 'package:provider/provider.dart';
  4. import '../curve_plot.dart';
  5. import '../symja.dart';
  6. /// 函数图像绘制:把一个或多个 y = f(x) 交给 Symja 采样,再用项目自己的
  7. /// [CurvePlot] 画出来。
  8. ///
  9. /// 为什么改成按钮触发的异步绘制:Symja 的求值都跑在原生侧的专用线程上,只能
  10. /// 异步拿结果;旧的 `math_expressions` 是同步的,才能每次 build 都重算一遍。
  11. /// 现在按下「绘制」采样一次,采样期间禁用按钮并显示忙碌状态。
  12. class FunctionPlotterPage extends StatefulWidget {
  13. const FunctionPlotterPage({super.key});
  14. @override
  15. State<FunctionPlotterPage> createState() => _FunctionPlotterPageState();
  16. }
  17. class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
  18. static const int _maxCurves = 5;
  19. /// 每条曲线的固定颜色,下标与输入框一一对应(沿用旧版配色)。
  20. static const List<Color> _curveColors = <Color>[
  21. Colors.blue,
  22. Colors.red,
  23. Colors.green,
  24. Colors.orange,
  25. Colors.purple,
  26. ];
  27. final List<TextEditingController> _exprs = <TextEditingController>[
  28. TextEditingController(text: 'x^2'),
  29. ];
  30. final TextEditingController _xMin = TextEditingController(text: '-5');
  31. final TextEditingController _xMax = TextEditingController(text: '5');
  32. final TextEditingController _yMin = TextEditingController(text: '-10');
  33. final TextEditingController _yMax = TextEditingController(text: '10');
  34. List<CurveSeries> _series = const <CurveSeries>[];
  35. /// 上一次绘制实际使用的 y 范围(没绘制过就是 null,交给 CurvePlot 自动定界)。
  36. double? _plotYMin;
  37. double? _plotYMax;
  38. /// 每个输入框的错误信息,与 [_exprs] 下标对齐;null 表示该项正常。
  39. List<String?> _errors = const <String?>[];
  40. /// 坐标范围本身的错误(与某条曲线无关),显示在绘图区。
  41. String? _rangeError;
  42. bool _busy = false;
  43. @override
  44. void initState() {
  45. super.initState();
  46. // 首帧后再绘制:context.read 需要 element 已经挂载到 Provider 之下。
  47. WidgetsBinding.instance.addPostFrameCallback((_) => _plot());
  48. }
  49. @override
  50. void dispose() {
  51. for (final c in _exprs) {
  52. c.dispose();
  53. }
  54. _xMin.dispose();
  55. _xMax.dispose();
  56. _yMin.dispose();
  57. _yMax.dispose();
  58. super.dispose();
  59. }
  60. void _addCurve() {
  61. if (_exprs.length >= _maxCurves) return;
  62. setState(() => _exprs.add(TextEditingController()));
  63. }
  64. void _removeCurve(int index) {
  65. if (_exprs.length <= 1) return;
  66. setState(() => _exprs.removeAt(index).dispose());
  67. }
  68. Future<void> _plot() async {
  69. if (_busy) return;
  70. final xMin = double.tryParse(_xMin.text.trim());
  71. final xMax = double.tryParse(_xMax.text.trim());
  72. final yMin = double.tryParse(_yMin.text.trim());
  73. final yMax = double.tryParse(_yMax.text.trim());
  74. if (xMin == null || xMax == null || yMin == null || yMax == null) {
  75. setState(() => _rangeError = '请输入有效的坐标范围');
  76. return;
  77. }
  78. if (xMin >= xMax || yMin >= yMax) {
  79. setState(() => _rangeError = '最小值必须小于最大值');
  80. return;
  81. }
  82. final session = context.read<SymjaSession>();
  83. setState(() {
  84. _busy = true;
  85. _rangeError = null;
  86. _plotYMin = yMin;
  87. _plotYMax = yMax;
  88. // 每次重绘都重建,长度与输入框对齐,旧错误自然被清掉。
  89. _errors = List<String?>.filled(_exprs.length, null);
  90. });
  91. final result = <CurveSeries>[];
  92. // 逐条采样:一条失败不影响其它条,错误只挂到它自己那一栏。
  93. for (var i = 0; i < _exprs.length; i++) {
  94. final raw = _exprs[i].text.trim();
  95. if (raw.isEmpty) continue;
  96. try {
  97. // 表达式**原样交给 Symja 解析** —— 不再自己转换,谁解析谁负责,
  98. // 免得"工具里算得出来、控制台里又不对"。
  99. final expr = raw;
  100. final s = await session.sample(
  101. expr,
  102. variable: 'x',
  103. from: xMin,
  104. to: xMax,
  105. count: 240,
  106. );
  107. if (!mounted) return;
  108. result.add(CurveSeries(
  109. label: raw,
  110. color: _curveColors[i],
  111. // 非有限值(极点处的 ComplexInfinity 等)原样保留,
  112. // CurvePlot 会把它们当成抬笔断点,这里不做过滤。
  113. points: <Offset>[
  114. for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]),
  115. ],
  116. ));
  117. } on SymjaException catch (e) {
  118. if (!mounted) return;
  119. _errors[i] = '$e';
  120. }
  121. }
  122. if (!mounted) return;
  123. setState(() {
  124. _series = result;
  125. _busy = false;
  126. });
  127. }
  128. @override
  129. Widget build(BuildContext context) {
  130. return Scaffold(
  131. appBar: AppBar(
  132. title: const Text('函数图像绘制', style: TextStyle(color: Colors.black)),
  133. backgroundColor: Colors.white,
  134. elevation: 1,
  135. actions: [
  136. IconButton(
  137. icon: const Icon(Icons.refresh),
  138. onPressed: _busy ? null : _plot,
  139. tooltip: '重新绘制',
  140. ),
  141. ],
  142. ),
  143. body: SingleChildScrollView(
  144. child: Padding(
  145. padding: const EdgeInsets.all(16),
  146. child: Column(
  147. children: [
  148. _buildInputs(),
  149. const SizedBox(height: 16),
  150. SizedBox(
  151. height: MediaQuery.of(context).size.height * 0.5,
  152. child: _buildPlotArea(),
  153. ),
  154. ],
  155. ),
  156. ),
  157. ),
  158. );
  159. }
  160. Widget _buildInputs() {
  161. return Card(
  162. elevation: 4,
  163. child: Padding(
  164. padding: const EdgeInsets.all(16),
  165. child: Column(
  166. crossAxisAlignment: CrossAxisAlignment.start,
  167. children: [
  168. const Text(
  169. '函数表达式 (使用 x 作为变量)',
  170. style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
  171. ),
  172. const SizedBox(height: 10),
  173. const Text(
  174. '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, pi, e',
  175. style: TextStyle(fontSize: 12, color: Colors.grey),
  176. ),
  177. const SizedBox(height: 10),
  178. LayoutBuilder(
  179. builder: (context, constraints) {
  180. final fields = <Widget>[
  181. _buildRangeField(_xMin, 'x最小值'),
  182. _buildRangeField(_xMax, 'x最大值'),
  183. _buildRangeField(_yMin, 'y最小值'),
  184. _buildRangeField(_yMax, 'y最大值'),
  185. ];
  186. // 窄屏竖向排,宽屏横向排。
  187. if (constraints.maxWidth < 600) {
  188. return Column(
  189. children: [
  190. for (var i = 0; i < fields.length; i++) ...[
  191. if (i > 0) const SizedBox(height: 10),
  192. fields[i],
  193. ],
  194. ],
  195. );
  196. }
  197. return Row(
  198. children: [
  199. for (var i = 0; i < fields.length; i++) ...[
  200. if (i > 0) const SizedBox(width: 10),
  201. Expanded(child: fields[i]),
  202. ],
  203. ],
  204. );
  205. },
  206. ),
  207. const SizedBox(height: 4),
  208. const Text(
  209. '注: x 范围决定采样区间,y 范围决定显示窗口;图上还能双指缩放、拖动、双击复位。',
  210. style: TextStyle(fontSize: 11, color: Colors.grey),
  211. ),
  212. const SizedBox(height: 10),
  213. Container(
  214. constraints: const BoxConstraints(maxHeight: 240),
  215. child: ListView.builder(
  216. shrinkWrap: true,
  217. itemCount: _exprs.length,
  218. itemBuilder: _buildExprRow,
  219. ),
  220. ),
  221. const SizedBox(height: 10),
  222. Row(
  223. children: [
  224. if (_exprs.length < _maxCurves)
  225. OutlinedButton.icon(
  226. onPressed: _busy ? null : _addCurve,
  227. icon: const Icon(Icons.add),
  228. label: const Text('添加函数'),
  229. ),
  230. const Spacer(),
  231. ElevatedButton.icon(
  232. onPressed: _busy ? null : _plot,
  233. icon: _busy
  234. ? const SizedBox(
  235. width: 16,
  236. height: 16,
  237. child: CircularProgressIndicator(strokeWidth: 2),
  238. )
  239. : const Icon(Icons.show_chart),
  240. label: Text(_busy ? '绘制中…' : '绘制图像'),
  241. style: ElevatedButton.styleFrom(
  242. backgroundColor: Colors.blue,
  243. foregroundColor: Colors.white,
  244. ),
  245. ),
  246. ],
  247. ),
  248. ],
  249. ),
  250. ),
  251. );
  252. }
  253. Widget _buildExprRow(BuildContext context, int index) {
  254. return Padding(
  255. padding: const EdgeInsets.symmetric(vertical: 5),
  256. child: Row(
  257. crossAxisAlignment: CrossAxisAlignment.start,
  258. children: [
  259. Container(
  260. width: 20,
  261. height: 20,
  262. margin: const EdgeInsets.only(top: 16),
  263. decoration: BoxDecoration(
  264. color: _curveColors[index],
  265. shape: BoxShape.circle,
  266. ),
  267. ),
  268. const SizedBox(width: 10),
  269. Expanded(
  270. child: Column(
  271. crossAxisAlignment: CrossAxisAlignment.start,
  272. children: [
  273. TextField(
  274. controller: _exprs[index],
  275. decoration: InputDecoration(
  276. hintText: '例如: x^2, Sin[x], Sqrt[x], Exp[x]',
  277. border: const OutlineInputBorder(),
  278. errorText: index < _errors.length ? _errors[index] : null,
  279. ),
  280. ),
  281. ],
  282. ),
  283. ),
  284. const SizedBox(width: 10),
  285. if (_exprs.length > 1)
  286. IconButton(
  287. icon: const Icon(Icons.remove, color: Colors.red),
  288. tooltip: '删除该函数',
  289. onPressed: () => _removeCurve(index),
  290. ),
  291. ],
  292. ),
  293. );
  294. }
  295. Widget _buildRangeField(TextEditingController controller, String label) {
  296. return TextField(
  297. controller: controller,
  298. decoration: InputDecoration(
  299. labelText: label,
  300. border: const OutlineInputBorder(),
  301. contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  302. ),
  303. keyboardType: TextInputType.numberWithOptions(
  304. signed: true,
  305. decimal: true,
  306. ),
  307. );
  308. }
  309. Widget _buildPlotArea() {
  310. return Card(
  311. elevation: 4,
  312. child: Padding(
  313. padding: const EdgeInsets.all(16),
  314. child: Column(
  315. children: [
  316. const Text(
  317. '函数图像',
  318. style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
  319. ),
  320. const SizedBox(height: 10),
  321. Expanded(child: _buildPlot()),
  322. ],
  323. ),
  324. ),
  325. );
  326. }
  327. Widget _buildPlot() {
  328. if (_busy) {
  329. return const Center(child: CircularProgressIndicator());
  330. }
  331. if (_rangeError != null) {
  332. return Center(
  333. child: Text(_rangeError!, style: const TextStyle(color: Colors.red)),
  334. );
  335. }
  336. if (_series.isEmpty) {
  337. return Center(
  338. child: Column(
  339. mainAxisAlignment: MainAxisAlignment.center,
  340. children: const [
  341. Icon(Icons.functions, size: 50, color: Colors.grey),
  342. SizedBox(height: 10),
  343. Text('暂无函数图像可显示', style: TextStyle(color: Colors.grey)),
  344. Text(
  345. '请输入函数表达式后点击「绘制图像」',
  346. style: TextStyle(color: Colors.grey, fontSize: 12),
  347. ),
  348. ],
  349. ),
  350. );
  351. }
  352. return CurvePlot(series: _series, yMin: _plotYMin, yMax: _plotYMax);
  353. }
  354. }