Browse Source

参数方程绘图改成等比例:圆不再画成椭圆

两个原因叠加,都得修:

1. 边界计算**只给 y 加了 5% 留白**,x 没加 —— 就算数据是 cos/sin 这种
   对称的,两轴跨度也不相等。
2. 绘图区是 Rect(46, 12) → (w-12, h-26),**宽度比高度少 58px**(左右要留
   轴标签的位置)—— 外层 widget 就算是正方形,里面也不是。

给 CurvePlot 加了 equalAspect:两轴同留白、同跨度、同中心,且绘图区
收成正方形。参数方程绘图开这个(圆/椭圆/螺线必须等比例);函数图像
不开 —— 它更希望横向铺满,而不是为了比例留白。

"两轴调成同跨度同中心"抽成了纯函数 equalizeAspect,直接测 ——
"圆看起来圆不圆"没法靠断言像素回归。顺带测出它在退化成一点时返回
零跨度,已加保护。

96 个测试通过(新增 4 个)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 1 month ago
parent
commit
8ca741db1e
3 changed files with 86 additions and 4 deletions
  1. 44 3
      lib/curve_plot.dart
  2. 2 1
      lib/tools/parametric_plotter.dart
  3. 40 0
      test/curve_plot_test.dart

+ 44 - 3
lib/curve_plot.dart

@@ -31,6 +31,7 @@ class CurvePlot extends StatefulWidget {
     this.yMin,
     this.yMax,
     this.showControls = true,
+    this.equalAspect = false,
   });
 
   final List<CurveSeries> series;
@@ -42,6 +43,12 @@ class CurvePlot extends StatefulWidget {
   /// 是否显示下面的滑动条。嵌在很小的对话框里时可以关掉。
   final bool showControls;
 
+  /// 等比例:两轴用**同样的单位长度**(圆看起来才是圆)。
+  ///
+  /// 参数方程、圆、极坐标这类图必须开 —— 默认关掉是因为函数图像
+  /// 更希望横向铺满,而不是为了保持比例留出大片空白。
+  final bool equalAspect;
+
   @override
   State<CurvePlot> createState() => _CurvePlotState();
 }
@@ -54,6 +61,25 @@ class _Bounds {
   double get span => max - min;
 }
 
+/// 把两轴范围调成**同跨度、同中心**(各边留 5% 余量)。
+///
+/// 抽成纯函数是为了能直接测 —— "圆是不是圆的"靠截图看像素太难回归了。
+/// 同跨度意味着同样的单位长度在两轴上对应同样多的像素。
+(double, double, double, double) equalizeAspect(
+    double x0, double x1, double y0, double y1) {
+  // 退化成一点(或非法值)时给个最小跨度,否则返回的还是零跨度
+  var span = math.max(x1 - x0, y1 - y0);
+  if (!span.isFinite || span <= 0) span = 1;
+  final pad = span * 0.05;
+  x0 -= pad;
+  x1 += pad;
+  y0 -= pad;
+  y1 += pad;
+  final cx = (x0 + x1) / 2, cy = (y0 + y1) / 2;
+  final half = math.max(x1 - x0, y1 - y0) / 2;
+  return (cx - half, cx + half, cy - half, cy + half);
+}
+
 class _CurvePlotState extends State<CurvePlot> {
   /// 当前视图窗口。null = 跟着数据自动定界。
   double? _x0, _x1, _y0, _y1;
@@ -85,6 +111,12 @@ class _CurvePlotState extends State<CurvePlot> {
       y0 -= 1;
       y1 += 1;
     }
+    if (widget.equalAspect) {
+      final r = equalizeAspect(x0, x1, y0, y1);
+      _dataX = _Bounds(r.$1, r.$2);
+      _dataY = _Bounds(r.$3, r.$4);
+      return;
+    }
     // 上下留 5% 余量,别让曲线顶到边
     final pad = (y1 - y0) * 0.05;
     _dataX = _Bounds(x0, x1);
@@ -129,7 +161,8 @@ class _CurvePlotState extends State<CurvePlot> {
         Expanded(
           child: ClipRect(
             child: CustomPaint(
-              painter: _CurvePainter(widget.series, x0, x1, y0, y1),
+              painter: _CurvePainter(
+                  widget.series, x0, x1, y0, y1, widget.equalAspect),
               child: const SizedBox.expand(),
             ),
           ),
@@ -200,10 +233,12 @@ class _CurvePlotState extends State<CurvePlot> {
 }
 
 class _CurvePainter extends CustomPainter {
-  _CurvePainter(this.series, this.x0, this.x1, this.y0, this.y1);
+  _CurvePainter(
+      this.series, this.x0, this.x1, this.y0, this.y1, this.equalAspect);
 
   final List<CurveSeries> series;
   final double x0, x1, y0, y1;
+  final bool equalAspect;
 
   static const _padLeft = 46.0;
   static const _padRight = 12.0;
@@ -217,12 +252,18 @@ class _CurvePainter extends CustomPainter {
       Paint()..color = const Color(0xFFFAFAFC),
     );
 
-    final plot = Rect.fromLTRB(
+    var plot = Rect.fromLTRB(
       _padLeft,
       _padTop,
       size.width - _padRight,
       size.height - _padBottom,
     );
+    if (equalAspect) {
+      // 把绘图区收成正方形 —— 否则宽度比高度少掉两边的轴标签宽度,
+      // 画出来的圆就是椭的。
+      final side = math.min(plot.width, plot.height);
+      plot = Rect.fromCenter(center: plot.center, width: side, height: side);
+    }
 
     double sx(double x) => plot.left + (x - x0) / (x1 - x0) * plot.width;
     double sy(double y) => plot.bottom - (y - y0) / (y1 - y0) * plot.height;

+ 2 - 1
lib/tools/parametric_plotter.dart

@@ -284,7 +284,8 @@ class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
                               style: TextStyle(fontSize: 16, color: Colors.grey),
                             ),
                           )
-                        : CurvePlot(series: _series)),
+                        // 参数方程常常是圆/椭圆/螺线,必须等比例
+                        : CurvePlot(series: _series, equalAspect: true)),
               ),
             ),
             const SizedBox(height: 16),

+ 40 - 0
test/curve_plot_test.dart

@@ -0,0 +1,40 @@
+// 绘图组件的纯逻辑测试。
+//
+// "圆看起来是不是圆的"没法靠断言像素,所以把决定比例的那步抽成纯函数来测。
+import 'package:flutter_test/flutter_test.dart';
+
+import 'package:cal/curve_plot.dart';
+
+void main() {
+  group('等比例:两轴调成同跨度同中心', () {
+    test('数据本来就对称(cos/sin)→ 两轴跨度严格相等', () {
+      final (x0, x1, y0, y1) = equalizeAspect(-1, 1, -1, 1);
+      expect(x1 - x0, closeTo(y1 - y0, 1e-12));
+      expect((x0 + x1) / 2, closeTo((y0 + y1) / 2, 1e-12));
+    });
+
+    test('两轴跨度不同 → 取大的那个,小的被撑开', () {
+      final (x0, x1, y0, y1) = equalizeAspect(0, 10, 0, 2);
+      expect(x1 - x0, closeTo(y1 - y0, 1e-9));
+      // 以大的跨度为准
+      expect(x1 - x0, closeTo(11, 1e-9));
+      // 中心不变
+      expect((x0 + x1) / 2, closeTo(5, 1e-9));
+      expect((y0 + y1) / 2, closeTo(1, 1e-9));
+    });
+
+    test('圆不会被压成椭圆 —— 单位长度在两轴上占的跨度一样', () {
+      final (x0, x1, y0, y1) = equalizeAspect(-1, 1, -1, 1);
+      final xSpan = x1 - x0, ySpan = y1 - y0;
+      // 半径 1 的圆:x 和 y 各自占的屏幕比例必须相同
+      expect(1 / xSpan, closeTo(1 / ySpan, 1e-12));
+    });
+
+    test('退化的数据(单点)也不会崩', () {
+      final (x0, x1, y0, y1) = equalizeAspect(3, 3, 5, 5);
+      expect(x1 > x0, isTrue);
+      expect(y1 > y0, isTrue);
+      expect(x1 - x0, closeTo(y1 - y0, 1e-9));
+    });
+  });
+}