parametric_plotter.dart 5.9 KB

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