parametric_plotter.dart 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. // tools/parametric_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. /// 参数方程绘图:x = f(t), y = g(t),用 Symja 采样后交给 [CurvePlot] 画。
  8. ///
  9. /// 采样要点:Symja 的 `sample` 一次只能算一个表达式,所以 x(t) 和 y(t) 各调用
  10. /// 一次,参数区间和点数完全一致,再把两个结果按下标配成 (x, y) 点。
  11. class ParametricPlotterPage extends StatefulWidget {
  12. const ParametricPlotterPage({super.key});
  13. @override
  14. State<ParametricPlotterPage> createState() => _ParametricPlotterPageState();
  15. }
  16. class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
  17. final TextEditingController _xCtrl = TextEditingController(text: 'cos(t)');
  18. final TextEditingController _yCtrl = TextEditingController(text: 'sin(t)');
  19. final TextEditingController _tMinCtrl = TextEditingController(text: '0');
  20. final TextEditingController _tMaxCtrl = TextEditingController(text: '6.3');
  21. final TextEditingController _stepCtrl = TextEditingController(text: '0.1');
  22. List<CurveSeries> _series = const <CurveSeries>[];
  23. String? _error;
  24. bool _busy = false;
  25. @override
  26. void initState() {
  27. super.initState();
  28. // 首帧后再绘制:context.read 需要 element 已经挂载到 Provider 之下。
  29. WidgetsBinding.instance.addPostFrameCallback((_) => _plot());
  30. }
  31. @override
  32. void dispose() {
  33. _xCtrl.dispose();
  34. _yCtrl.dispose();
  35. _tMinCtrl.dispose();
  36. _tMaxCtrl.dispose();
  37. _stepCtrl.dispose();
  38. super.dispose();
  39. }
  40. Future<void> _plot() async {
  41. if (_busy) return;
  42. final xRaw = _xCtrl.text.trim();
  43. final yRaw = _yCtrl.text.trim();
  44. if (xRaw.isEmpty || yRaw.isEmpty) {
  45. setState(() => _error = '请填写 x(t) 和 y(t) 表达式');
  46. return;
  47. }
  48. final tMin = double.tryParse(_tMinCtrl.text.trim());
  49. final tMax = double.tryParse(_tMaxCtrl.text.trim());
  50. final step = double.tryParse(_stepCtrl.text.trim());
  51. if (tMin == null || tMax == null || step == null) {
  52. setState(() => _error = '请输入有效的 t 范围与步长');
  53. return;
  54. }
  55. if (tMin >= tMax) {
  56. setState(() => _error = 't最小值必须小于t最大值');
  57. return;
  58. }
  59. if (step <= 0) {
  60. setState(() => _error = '步长必须大于0');
  61. return;
  62. }
  63. // Symja 的 sample 只接受「点数」,这里把用户给的步长换算成点数,
  64. // 保留步长输入的含义;再做上下限保护,避免点数过多拖垮求值。
  65. final count = ((tMax - tMin) / step).round() + 1;
  66. final n = count.clamp(2, 5000).toInt();
  67. final session = context.read<SymjaSession>();
  68. setState(() {
  69. _busy = true;
  70. _error = null;
  71. });
  72. List<CurveSeries> series = const <CurveSeries>[];
  73. try {
  74. // 同一个 t 区间、同一点数分别采样两条曲线——Symja 一次只能算一个表达式。
  75. final xs = await session.sample(
  76. toSymjaSyntax(xRaw),
  77. variable: 't',
  78. from: tMin,
  79. to: tMax,
  80. count: n,
  81. );
  82. final ys = await session.sample(
  83. toSymjaSyntax(yRaw),
  84. variable: 't',
  85. from: tMin,
  86. to: tMax,
  87. count: n,
  88. );
  89. if (!mounted) return;
  90. final m = xs.ys.length < ys.ys.length ? xs.ys.length : ys.ys.length;
  91. series = <CurveSeries>[
  92. CurveSeries(
  93. label: 'x=$xRaw, y=$yRaw',
  94. color: Colors.blue,
  95. // NaN(极点/无定义点)照传,CurvePlot 会当成断点抬笔。
  96. points: <Offset>[
  97. for (var i = 0; i < m; i++) Offset(xs.ys[i], ys.ys[i]),
  98. ],
  99. ),
  100. ];
  101. } on SymjaException catch (e) {
  102. if (!mounted) return;
  103. setState(() {
  104. _error = '$e';
  105. _busy = false;
  106. });
  107. return;
  108. }
  109. if (!mounted) return;
  110. setState(() {
  111. _series = series;
  112. _busy = false;
  113. });
  114. }
  115. void _applyPreset(String xExpr, String yExpr, String tMin, String tMax) {
  116. setState(() {
  117. _xCtrl.text = xExpr;
  118. _yCtrl.text = yExpr;
  119. _tMinCtrl.text = tMin;
  120. _tMaxCtrl.text = tMax;
  121. _stepCtrl.text = '0.1';
  122. });
  123. _plot();
  124. }
  125. @override
  126. Widget build(BuildContext context) {
  127. return Scaffold(
  128. appBar: AppBar(
  129. title: const Text('参数方程绘图', style: TextStyle(color: Colors.black)),
  130. backgroundColor: Colors.white,
  131. elevation: 1,
  132. ),
  133. body: SingleChildScrollView(
  134. child: Column(
  135. children: [
  136. _buildInputPanel(),
  137. _buildGraph(),
  138. _buildExamples(),
  139. ],
  140. ),
  141. ),
  142. );
  143. }
  144. Widget _buildInputPanel() {
  145. return Card(
  146. elevation: 4,
  147. margin: const EdgeInsets.all(16),
  148. child: Padding(
  149. padding: const EdgeInsets.all(16),
  150. child: Column(
  151. crossAxisAlignment: CrossAxisAlignment.start,
  152. children: [
  153. const Text(
  154. '参数方程设置',
  155. style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
  156. ),
  157. const SizedBox(height: 16),
  158. _buildExprField(_xCtrl, 'x = f(t)', '例如: cos(t), t^2, Cos[t]'),
  159. const SizedBox(height: 12),
  160. _buildExprField(_yCtrl, 'y = g(t)', '例如: sin(t), t^3, Sin[t]'),
  161. const SizedBox(height: 16),
  162. Row(
  163. children: [
  164. Expanded(child: _buildNumberField(_tMinCtrl, 't最小值')),
  165. const SizedBox(width: 12),
  166. Expanded(child: _buildNumberField(_tMaxCtrl, 't最大值')),
  167. const SizedBox(width: 12),
  168. Expanded(child: _buildNumberField(_stepCtrl, '步长')),
  169. ],
  170. ),
  171. const SizedBox(height: 12),
  172. if (_error != null)
  173. Container(
  174. width: double.infinity,
  175. padding: const EdgeInsets.all(12),
  176. decoration: BoxDecoration(
  177. color: Colors.red[50],
  178. borderRadius: BorderRadius.circular(8),
  179. border: Border.all(color: Colors.red),
  180. ),
  181. child: Text(_error!, style: const TextStyle(color: Colors.red)),
  182. ),
  183. const SizedBox(height: 16),
  184. ElevatedButton.icon(
  185. onPressed: _busy ? null : _plot,
  186. icon: _busy
  187. ? const SizedBox(
  188. width: 16,
  189. height: 16,
  190. child: CircularProgressIndicator(strokeWidth: 2),
  191. )
  192. : const Icon(Icons.show_chart),
  193. label: Text(_busy ? '绘制中…' : '绘制图像'),
  194. style: ElevatedButton.styleFrom(
  195. minimumSize: const Size(double.infinity, 50),
  196. ),
  197. ),
  198. ],
  199. ),
  200. ),
  201. );
  202. }
  203. /// 表达式输入框 + 语法转换提示(只有真的改写了才显示)。
  204. Widget _buildExprField(
  205. TextEditingController controller,
  206. String label,
  207. String hint,
  208. ) {
  209. final raw = controller.text.trim();
  210. final rewrite = raw.isEmpty ? null : symjaRewriteOf(raw);
  211. return Column(
  212. crossAxisAlignment: CrossAxisAlignment.start,
  213. children: [
  214. TextField(
  215. controller: controller,
  216. // 输入时重建,让下面的语法转换提示实时更新。
  217. onChanged: (_) => setState(() {}),
  218. decoration: InputDecoration(
  219. labelText: label,
  220. hintText: hint,
  221. border: const OutlineInputBorder(),
  222. ),
  223. ),
  224. if (rewrite != null)
  225. Padding(
  226. padding: const EdgeInsets.only(top: 4, left: 4),
  227. child: Text(
  228. '已按 Symja 语法解释为: $rewrite',
  229. style: TextStyle(fontSize: 11, color: Colors.grey[600]),
  230. ),
  231. ),
  232. ],
  233. );
  234. }
  235. Widget _buildNumberField(TextEditingController controller, String label) {
  236. return TextField(
  237. controller: controller,
  238. decoration: InputDecoration(
  239. labelText: label,
  240. border: const OutlineInputBorder(),
  241. ),
  242. keyboardType: TextInputType.numberWithOptions(
  243. signed: true,
  244. decimal: true,
  245. ),
  246. );
  247. }
  248. Widget _buildGraph() {
  249. return Card(
  250. elevation: 4,
  251. margin: const EdgeInsets.all(16),
  252. child: Padding(
  253. padding: const EdgeInsets.all(16),
  254. child: Column(
  255. crossAxisAlignment: CrossAxisAlignment.start,
  256. children: [
  257. const Text(
  258. '参数方程图像',
  259. style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
  260. ),
  261. const SizedBox(height: 16),
  262. // 用 AspectRatio 强制正方形,参数曲线的 x/y 比例才不会被拉伸。
  263. AspectRatio(
  264. aspectRatio: 1,
  265. child: Container(
  266. decoration: BoxDecoration(
  267. border: Border.all(color: Colors.grey[300]!),
  268. borderRadius: BorderRadius.circular(8),
  269. ),
  270. child: _busy
  271. ? const Center(child: CircularProgressIndicator())
  272. : (_series.isEmpty
  273. ? const Center(
  274. child: Text(
  275. '暂无数据',
  276. style: TextStyle(fontSize: 16, color: Colors.grey),
  277. ),
  278. )
  279. : CurvePlot(series: _series)),
  280. ),
  281. ),
  282. const SizedBox(height: 16),
  283. Text(
  284. '方程: x = ${_xCtrl.text}, y = ${_yCtrl.text}',
  285. style: TextStyle(fontSize: 14, color: Colors.grey[600]),
  286. ),
  287. Text(
  288. '参数范围: t ∈ [${_tMinCtrl.text}, ${_tMaxCtrl.text}], 步长: ${_stepCtrl.text}',
  289. style: TextStyle(fontSize: 14, color: Colors.grey[600]),
  290. ),
  291. ],
  292. ),
  293. ),
  294. );
  295. }
  296. Widget _buildExamples() {
  297. return Card(
  298. elevation: 4,
  299. margin: const EdgeInsets.all(16),
  300. child: Padding(
  301. padding: const EdgeInsets.all(16),
  302. child: Column(
  303. crossAxisAlignment: CrossAxisAlignment.start,
  304. children: [
  305. const Text(
  306. '示例方程',
  307. style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
  308. ),
  309. const SizedBox(height: 12),
  310. Wrap(
  311. spacing: 8,
  312. runSpacing: 8,
  313. children: [
  314. _buildExampleChip('椭圆', '2*cos(t)', 'sin(t)', '0', '6.3'),
  315. _buildExampleChip('螺旋线', 't*cos(t)', 't*sin(t)', '0', '12.6'),
  316. _buildExampleChip('抛物线', 't', 't^2', '-2', '2'),
  317. _buildExampleChip(
  318. '心形线',
  319. '2*cos(t)-cos(2*t)',
  320. '2*sin(t)-sin(2*t)',
  321. '0',
  322. '6.3',
  323. ),
  324. ],
  325. ),
  326. ],
  327. ),
  328. ),
  329. );
  330. }
  331. Widget _buildExampleChip(
  332. String title,
  333. String xExpr,
  334. String yExpr,
  335. String tMin,
  336. String tMax,
  337. ) {
  338. return ActionChip(
  339. label: Text(title),
  340. onPressed: () => _applyPreset(xExpr, yExpr, tMin, tMax),
  341. backgroundColor: Colors.blue[50],
  342. );
  343. }
  344. }