parametric_plotter.dart 6.3 KB

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