parametric_plotter.dart 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. // tools/parametric_plotter.dart
  2. //
  3. // 参数方程绘图:x = f(t), y = g(t),用 Symja 采样后交给 [CurvePlot] 画。
  4. //
  5. // 采样要点:Symja 的 `sample` 一次只能算一个表达式,所以 x(t) 和 y(t) 各调用
  6. // 一次,参数区间和点数完全一致,再把两个结果按下标配成 (x, y) 点。
  7. import 'package:flutter/material.dart';
  8. import '../curve_plot.dart';
  9. import '../symja.dart';
  10. import 'tool_kit.dart';
  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 ToolField _x = ToolField(
  18. 'x = f(t)',
  19. hint: '例如 cos(t)、t^2',
  20. keyboard: TextInputType.text,
  21. );
  22. final ToolField _y = ToolField(
  23. 'y = g(t)',
  24. hint: '例如 sin(t)、t^3',
  25. keyboard: TextInputType.text,
  26. );
  27. final ToolField _tMin = ToolField('t 最小值', hint: '例如 0');
  28. final ToolField _tMax = ToolField('t 最大值', hint: '例如 6.3');
  29. final ToolField _step = ToolField('步长', hint: '例如 0.1');
  30. /// 稳定的列表对象:外壳靠 `identical(fields)` 判断要不要清空结果,
  31. /// 每次 build 新建 List 会让主题一变结果就被清掉。
  32. late final List<ToolField> _fields = [_x, _y, _tMin, _tMax, _step];
  33. /// 给外壳换 key 用。示例按钮改完输入框后把 key 一换,外壳就会重建并
  34. /// autoRun,于是"点了示例就重算"这件事不用外壳额外开放接口。
  35. int _runToken = 0;
  36. @override
  37. void initState() {
  38. super.initState();
  39. _x.controller.text = 'cos(t)';
  40. _y.controller.text = 'sin(t)';
  41. _tMin.controller.text = '0';
  42. _tMax.controller.text = '6.3';
  43. _step.controller.text = '0.1';
  44. }
  45. void _applyPreset(String x, String y, String tMin, String tMax) {
  46. setState(() {
  47. _x.controller.text = x;
  48. _y.controller.text = y;
  49. _tMin.controller.text = tMin;
  50. _tMax.controller.text = tMax;
  51. _step.controller.text = '0.1';
  52. _runToken++; // 换 key → 外壳重建 → autoRun 重新采样
  53. });
  54. }
  55. Widget _intro() => Column(
  56. crossAxisAlignment: CrossAxisAlignment.start,
  57. children: [
  58. Wrap(
  59. spacing: 8,
  60. runSpacing: 8,
  61. children: [
  62. ActionChip(
  63. label: const Text('椭圆'),
  64. onPressed: () => _applyPreset('2*cos(t)', 'sin(t)', '0', '6.3'),
  65. ),
  66. ActionChip(
  67. label: const Text('螺旋线'),
  68. onPressed: () => _applyPreset('t*cos(t)', 't*sin(t)', '0', '12.6'),
  69. ),
  70. ActionChip(
  71. label: const Text('抛物线'),
  72. onPressed: () => _applyPreset('t', 't^2', '-2', '2'),
  73. ),
  74. ActionChip(
  75. label: const Text('心形线'),
  76. onPressed: () => _applyPreset(
  77. '2*cos(t)-cos(2*t)',
  78. '2*sin(t)-sin(2*t)',
  79. '0',
  80. '6.3',
  81. ),
  82. ),
  83. ],
  84. ),
  85. ],
  86. );
  87. @override
  88. Widget build(BuildContext context) => ToolScaffold(
  89. // key 一换,外壳整体重建并重新 autoRun —— 示例按钮靠它重算。
  90. key: ValueKey<int>(_runToken),
  91. title: '参数方程绘图',
  92. autoRun: true,
  93. fields: _fields,
  94. intro: _intro(),
  95. compute: (session) async {
  96. final xRaw = _x.text;
  97. final yRaw = _y.text;
  98. final tMinRaw = _tMin.text;
  99. final tMaxRaw = _tMax.text;
  100. final stepRaw = _step.text;
  101. // 校验:都是格式检查,Dart 里做即可。
  102. if (xRaw.isEmpty || yRaw.isEmpty) {
  103. throw Exception('请填写 x(t) 和 y(t) 表达式');
  104. }
  105. final tMin = double.tryParse(tMinRaw);
  106. final tMax = double.tryParse(tMaxRaw);
  107. final step = double.tryParse(stepRaw);
  108. if (tMin == null || tMax == null || step == null) {
  109. throw Exception('请输入有效的 t 范围与步长');
  110. }
  111. if (!tMin.isFinite || !tMax.isFinite || !step.isFinite) {
  112. throw Exception('t 范围与步长必须是有限数');
  113. }
  114. if (tMin >= tMax) {
  115. throw Exception('t 最小值必须小于 t 最大值');
  116. }
  117. if (step <= 0) {
  118. throw Exception('步长必须大于 0');
  119. }
  120. // 把用户给的步长换算成点数,再做上下限保护,避免点数过多拖垮求值。
  121. final n = (((tMax - tMin) / step).round() + 1).clamp(2, 5000).toInt();
  122. final List<CurveSeries> series;
  123. try {
  124. // 同一个 t 区间、同一点数分别采样两条曲线——Symja 一次只能算一个表达式。
  125. final xs = await session.sample(
  126. xRaw,
  127. variable: 't',
  128. from: tMin,
  129. to: tMax,
  130. count: n,
  131. );
  132. final ys = await session.sample(
  133. yRaw,
  134. variable: 't',
  135. from: tMin,
  136. to: tMax,
  137. count: n,
  138. );
  139. final m = xs.ys.length < ys.ys.length ? xs.ys.length : ys.ys.length;
  140. series = <CurveSeries>[
  141. CurveSeries(
  142. label: 'x=$xRaw, y=$yRaw',
  143. color: Colors.blue,
  144. // NaN(极点/无定义点)照传,CurvePlot 会当成断点抬笔。
  145. points: <Offset>[
  146. for (var i = 0; i < m; i++) Offset(xs.ys[i], ys.ys[i]),
  147. ],
  148. ),
  149. ];
  150. } on SymjaException catch (e) {
  151. throw Exception('采样失败:$e');
  152. }
  153. return [
  154. ToolChart(
  155. '参数方程图像',
  156. // 内部用 Expanded,必须给确定高度;参数曲线常常是圆/椭圆,
  157. // 必须等比例(equalAspect: true)。
  158. SizedBox(
  159. height: 360,
  160. child: CurvePlot(series: series, equalAspect: true),
  161. ),
  162. ),
  163. ToolText(
  164. '方程',
  165. 'x = $xRaw,y = $yRaw;t ∈ [$tMinRaw, $tMaxRaw],'
  166. '步长 $stepRaw(共 $n 个采样点)',
  167. ),
  168. ];
  169. },
  170. );
  171. }