function_plotter.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. // tools/function_plotter.dart
  2. import 'package:flutter/material.dart';
  3. import 'package:provider/provider.dart';
  4. import '../curve_plot.dart';
  5. import '../expr_syntax.dart';
  6. import '../symja.dart';
  7. /// 函数图像绘制:把一个或多个 y = f(x) 交给 Symja 采样,再用项目自己的
  8. /// [CurvePlot] 画出来。
  9. ///
  10. /// 为什么改成按钮触发的异步绘制:Symja 的求值都跑在原生侧的专用线程上,只能
  11. /// 异步拿结果;旧的 `math_expressions` 是同步的,才能每次 build 都重算一遍。
  12. /// 现在按下「绘制」采样一次,采样期间禁用按钮并显示忙碌状态。
  13. class FunctionPlotterPage extends StatefulWidget {
  14. const FunctionPlotterPage({super.key});
  15. @override
  16. State<FunctionPlotterPage> createState() => _FunctionPlotterPageState();
  17. }
  18. class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
  19. static const int _maxCurves = 5;
  20. /// 每条曲线的固定颜色,下标与输入框一一对应(沿用旧版配色)。
  21. static const List<Color> _curveColors = <Color>[
  22. Colors.blue,
  23. Colors.red,
  24. Colors.green,
  25. Colors.orange,
  26. Colors.purple,
  27. ];
  28. final List<TextEditingController> _exprs = <TextEditingController>[
  29. TextEditingController(text: 'x^2'),
  30. ];
  31. final TextEditingController _xMin = TextEditingController(text: '-5');
  32. final TextEditingController _xMax = TextEditingController(text: '5');
  33. final TextEditingController _yMin = TextEditingController(text: '-10');
  34. final TextEditingController _yMax = TextEditingController(text: '10');
  35. List<CurveSeries> _series = const <CurveSeries>[];
  36. /// 上一次绘制实际使用的 y 范围(没绘制过就是 null,交给 CurvePlot 自动定界)。
  37. double? _plotYMin;
  38. double? _plotYMax;
  39. /// 每个输入框的错误信息,与 [_exprs] 下标对齐;null 表示该项正常。
  40. List<String?> _errors = const <String?>[];
  41. /// 坐标范围本身的错误(与某条曲线无关),显示在绘图区。
  42. String? _rangeError;
  43. bool _busy = false;
  44. @override
  45. void initState() {
  46. super.initState();
  47. // 首帧后再绘制:context.read 需要 element 已经挂载到 Provider 之下。
  48. WidgetsBinding.instance.addPostFrameCallback((_) => _plot());
  49. }
  50. @override
  51. void dispose() {
  52. for (final c in _exprs) {
  53. c.dispose();
  54. }
  55. _xMin.dispose();
  56. _xMax.dispose();
  57. _yMin.dispose();
  58. _yMax.dispose();
  59. super.dispose();
  60. }
  61. void _addCurve() {
  62. if (_exprs.length >= _maxCurves) return;
  63. setState(() => _exprs.add(TextEditingController()));
  64. }
  65. void _removeCurve(int index) {
  66. if (_exprs.length <= 1) return;
  67. setState(() => _exprs.removeAt(index).dispose());
  68. }
  69. Future<void> _plot() async {
  70. if (_busy) return;
  71. final xMin = double.tryParse(_xMin.text.trim());
  72. final xMax = double.tryParse(_xMax.text.trim());
  73. final yMin = double.tryParse(_yMin.text.trim());
  74. final yMax = double.tryParse(_yMax.text.trim());
  75. if (xMin == null || xMax == null || yMin == null || yMax == null) {
  76. setState(() => _rangeError = '请输入有效的坐标范围');
  77. return;
  78. }
  79. if (xMin >= xMax || yMin >= yMax) {
  80. setState(() => _rangeError = '最小值必须小于最大值');
  81. return;
  82. }
  83. final session = context.read<SymjaSession>();
  84. setState(() {
  85. _busy = true;
  86. _rangeError = null;
  87. _plotYMin = yMin;
  88. _plotYMax = yMax;
  89. // 每次重绘都重建,长度与输入框对齐,旧错误自然被清掉。
  90. _errors = List<String?>.filled(_exprs.length, null);
  91. });
  92. final result = <CurveSeries>[];
  93. // 逐条采样:一条失败不影响其它条,错误只挂到它自己那一栏。
  94. for (var i = 0; i < _exprs.length; i++) {
  95. final raw = _exprs[i].text.trim();
  96. if (raw.isEmpty) continue;
  97. try {
  98. // 用户可能沿用旧的小写括号语法,先转成 Symja 语法再采样。
  99. final expr = toSymjaSyntax(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. final raw = _exprs[index].text.trim();
  255. // 只有在语法真的被改写时才提示,避免对已经正确的输入刷屏。
  256. final rewrite = raw.isEmpty ? null : symjaRewriteOf(raw);
  257. return Padding(
  258. padding: const EdgeInsets.symmetric(vertical: 5),
  259. child: Row(
  260. crossAxisAlignment: CrossAxisAlignment.start,
  261. children: [
  262. Container(
  263. width: 20,
  264. height: 20,
  265. margin: const EdgeInsets.only(top: 16),
  266. decoration: BoxDecoration(
  267. color: _curveColors[index],
  268. shape: BoxShape.circle,
  269. ),
  270. ),
  271. const SizedBox(width: 10),
  272. Expanded(
  273. child: Column(
  274. crossAxisAlignment: CrossAxisAlignment.start,
  275. children: [
  276. TextField(
  277. controller: _exprs[index],
  278. // 输入时重建,让下面的语法转换提示实时更新。
  279. onChanged: (_) => setState(() {}),
  280. decoration: InputDecoration(
  281. hintText: '例如: x^2, sin(x), Sin[x]',
  282. border: const OutlineInputBorder(),
  283. errorText: index < _errors.length ? _errors[index] : null,
  284. ),
  285. ),
  286. if (rewrite != null)
  287. Padding(
  288. padding: const EdgeInsets.only(top: 4, left: 4),
  289. child: Text(
  290. '已按 Symja 语法解释为: $rewrite',
  291. style: TextStyle(fontSize: 11, color: Colors.grey[600]),
  292. ),
  293. ),
  294. ],
  295. ),
  296. ),
  297. const SizedBox(width: 10),
  298. if (_exprs.length > 1)
  299. IconButton(
  300. icon: const Icon(Icons.remove, color: Colors.red),
  301. tooltip: '删除该函数',
  302. onPressed: () => _removeCurve(index),
  303. ),
  304. ],
  305. ),
  306. );
  307. }
  308. Widget _buildRangeField(TextEditingController controller, String label) {
  309. return TextField(
  310. controller: controller,
  311. decoration: InputDecoration(
  312. labelText: label,
  313. border: const OutlineInputBorder(),
  314. contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  315. ),
  316. keyboardType: TextInputType.numberWithOptions(
  317. signed: true,
  318. decimal: true,
  319. ),
  320. );
  321. }
  322. Widget _buildPlotArea() {
  323. return Card(
  324. elevation: 4,
  325. child: Padding(
  326. padding: const EdgeInsets.all(16),
  327. child: Column(
  328. children: [
  329. const Text(
  330. '函数图像',
  331. style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
  332. ),
  333. const SizedBox(height: 10),
  334. Expanded(child: _buildPlot()),
  335. ],
  336. ),
  337. ),
  338. );
  339. }
  340. Widget _buildPlot() {
  341. if (_busy) {
  342. return const Center(child: CircularProgressIndicator());
  343. }
  344. if (_rangeError != null) {
  345. return Center(
  346. child: Text(_rangeError!, style: const TextStyle(color: Colors.red)),
  347. );
  348. }
  349. if (_series.isEmpty) {
  350. return Center(
  351. child: Column(
  352. mainAxisAlignment: MainAxisAlignment.center,
  353. children: const [
  354. Icon(Icons.functions, size: 50, color: Colors.grey),
  355. SizedBox(height: 10),
  356. Text('暂无函数图像可显示', style: TextStyle(color: Colors.grey)),
  357. Text(
  358. '请输入函数表达式后点击「绘制图像」',
  359. style: TextStyle(color: Colors.grey, fontSize: 12),
  360. ),
  361. ],
  362. ),
  363. );
  364. }
  365. return CurvePlot(series: _series, yMin: _plotYMin, yMax: _plotYMax);
  366. }
  367. }