parametric_plotter.dart 11 KB

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